Reference
Lua extensions
Add tools, slash commands, provider protocols, and TUI hooks in Lua. Policy and config layers live in configuration; install and update commands in the command line.
Quick start
Save this file and start panto, then type /greet you.
local panto = require("panto") -- there is no global
return {
name = "greet-extension",
activate = function()
panto.ext.register_command {
name = "greet",
description = "Print a greeting.",
handler = function(args)
local who = args ~= "" and args or "world"
return "[greet] hello, " .. who .. "!"
end,
}
end,
}| A source returns | Meaning |
|---|---|
| { name, activate } | Entry; activate() registers anything. |
| { name, description, schema, handler } | One tool. |
| A list of these | Several entries. |
The entry name drives shadowing and allow/deny policy; it need not match what it registers.
| Namespace | Covers |
|---|---|
| panto.ext | Registration, events, host agent, helpers |
| panto.text | Display-width text helpers |
| panto | Agents, conversations, stores, deciders, extractors, _VERSION. Other underscore members are internal. |
Keep top-level code side-effect free
panto evaluates every source, even shadowed and denied ones. Do work inactivate().
Loading extensions
panto scans extensions/ and tools/ at startup; both share one entry namespace. Precedence, highest first:
| Layer | Directories |
|---|---|
| local | ./.panto/local/{extensions,tools} |
| project | ./.panto/{extensions,tools} |
| user | ${XDG_CONFIG_HOME:-$HOME/.config}/panto/{extensions,tools} |
| base | ${XDG_DATA_HOME:-$HOME/.local/share}/panto/base/{extensions,tools} |
- A source is a
.luafile or a directory withinit.lua; directories can require sibling modules. - Symlinks are followed. Dotfiles and
_-prefixed files are skipped. - Each layer can add
[extensions].paths(relative to the project) and[extensions].rocks. - Within a layer: directories beat paths beat rocks. Higher precedence shadows the same name.
- Missing configured rocks install at startup; each is required by the first token of its spec.
| Command | Does |
|---|---|
| panto extensions install rock:<spec> | Installs a rock and records it in state.toml for future startup loading. |
| panto extensions update | Updates managed git extensions and reinstalls configured rocks. |
Allow and deny
[extensions].allow and deny match entry names; no match allows. A denied entry denies everything it registers.
| Pattern | Matches |
|---|---|
| agent.notes | That name |
| agent.* | One segment: agent.notes, not agent.git.status |
| agent.** | One or more segments: both |
| deny = ["**"] | Everything; add allow rules for a whitelist |
Rules accumulate across layers: higher layer wins, then the more specific pattern, then deny on a tie. Format: extension policy.
Load errors
| Situation | Result |
|---|---|
A file source fails to evaluate, or activate() errors | Loading aborts |
| Same entry name twice at the same precedence | Loading aborts |
| Registered tool, command, or protocol name is duplicated | Loading aborts |
| Command name matches a built-in slash command | Startup fails |
| A rock won’t load or returns no valid entry | Logged, skipped |
Paths: panto.ext.dirs
Plain data, ready before evaluation. Build paths from it instead of reading HOME or XDG_*.
$HOME exists.--rig: only base and the rig root, named bundle, and no home.for _, layer in ipairs(require("panto").ext.dirs.layers) do
print(layer.name, layer.dir .. "/skills")
endLifetime
One Lua state lasts a TUI session; switching sessions loses globals. Registered themes survive.
| Action | Lua state |
|---|---|
| /new, /resume, choosing a branch in /tree | Fresh runtime; sources evaluate and activate again. |
register_tool
Give the model a tool: return a declaration from a source, or register it during activation.
return {
name = "echo",
description = "Echo a message.",
schema = {
type = "object",
properties = { message = { type = "string" } },
required = { "message" },
},
handler = function(input, context)
return context.tool_name .. ": " .. input.message
end,
}luv.| Handler returns | Result |
|---|---|
| String | Output text |
| { text?, attachments? } | Text plus attachments, each { data, media_type? } with raw bytes |
| Anything else, or malformed fields | Tool error |
| Context field | Contents |
|---|---|
| tool_call_id, tool_name | Call id and tool name. |
register_command
Add a slash command; see the Quick start.
To hand the terminal to a program, call run_foreground; it works only inside a TUI slash-command handler. panto restores the terminal, runs it with inherited stdio, then redraws.
handler = function()
local r = panto.ext.run_foreground { argv = { "lazygit" } }
-- r.success, plus one of r.exit_code, r.signal, r.stopped, r.unknown
end,argv must be a non-empty dense array (no gaps) of NUL-free strings, starting with a non-empty program name.
Events: on & emit
Observe lifecycle events or customize render events. Render events use the render_ prefix; they are emitted by the host session’s presentation path, including print mode (where rendered output is discarded), not by Lua-built agents. session_start fires for the primary session and on the first run of each Lua-built agent; on a child agent it carries only session_id and resumed (the primary event also has version, cwd, and model). tool_result fires once per call dispatched through the Lua tool source, after its batch settles. In these per-agent callbacks, panto.ext.agent resolves to the agent that emitted the event.
data is ignored); no-op without an attached bus.Each event has name and its documented fields. App render_ events and .custom events support get_component()/set_component(). A custom event starts without a component, so get_component() returns nil and set_component() can install one; this includes session_ready, session_end, and suspend. Typed-payload events session_start, turn_*, command_*, tool_result, and server_request have no component; either method raises event '<name>' has no component. Empty optional strings read as nil.
| Lifecycle event | Fields |
|---|---|
| session_start | Primary: version, cwd, model, session_id, resumed; child agent: session_id, resumed only |
| session_ready, session_end | None (host session; not Lua-built agents) |
| turn_start, turn_interrupt, turn_end | exit_requested; reason (nil at start; otherwise completed, interrupted, failed, or exit_requested) |
| tool_result | Final output, result input JSON, result_ok, call id, and tool_name when known |
| command_start, command_end | command_name (! or !! for shell), trimmed args |
| server_request | request_id, api_style, request_json; panto serve only |
| suspend | None; fires before Ctrl+Z suspends the process. No event fires on resume. |
| agent_submission, custom names | None (agent_submission also wakes the TUI) |
The former session_start header component event is now render_header; session_start remains a lifecycle event without a component. The former compaction component event is now render_compaction. The user_message event is removed; use read-only render_user_message to observe the transcript. Render events also replace the old unprefixed thinking, assistant-text, and tool component events.
| Render event | Fields |
|---|---|
| render_header | Same primary-session fields as session_start |
| render_user_message | Submitted message text (read-only) |
| render_thinking, render_thinking_delta, render_thinking_complete | index; delta adds this chunk as delta and accumulated text; complete carries final text |
| render_assistant_text, render_assistant_text_delta, render_assistant_text_complete | Same fields and delta/complete behavior as thinking |
| render_compaction | summary |
Tool render events fire in this order. render_tool fires when a tool-use block starts, before its name is known; render_tool_details supplies the resolved name. Collapse changes can occur separately. Empty optional strings read as nil.
| Tool render event | Payload highlights |
|---|---|
| render_tool | index, collapsed; tool_name is not yet known |
| render_tool_details | index, tool_name, call id |
| render_tool_delta | index, tool_name, argument chunk delta, accumulated JSON input |
| render_tool_call_complete | Final JSON-string input, tool_name, call id |
| render_tool_dispatch_start | Execution starts; tool_name, call id, input |
| render_tool_output_delta | Chunk output_delta; accumulated output |
| render_tool_result | Final output, result_ok, tool_name, call id |
| render_tool_collapse | New collapsed; fires when you toggle collapse |
tool_result is the lifecycle notification; its writable output is applied before the result is returned to the agent. render_tool_result is the separate TUI display boundary.
Rewriting events
Only these two lifecycle fields are writable. Later handlers see earlier writes; the last write wins. Render events, including render_tool_call_complete.input, are read-only. A tool_result.output override is applied only to a successful tool result: it replaces all text parts with one text part and preserves media parts. On failure the override is ignored.
| Writable field | Value |
|---|---|
| tool_result.output | String; applied before the tool result is returned to the agent |
| server_request.request_json | String up to [server] max_body_bytes; validated before routing |
local panto = require("panto")
panto.ext.on("server_request", function(event)
local request = panto.ext.json.decode(event.request_json)
if request then
request.model = "provider:alias"
event.request_json = panto.ext.json.encode(request)
end
end)Handler rules
- Handlers finish synchronously. Don’t await inside live TUI callbacks; startup or teardown hooks can await when the loop is not running.
- Errors are logged and swallowed. In
server_request, an error or invalid edit fails that HTTP request and discards staged edits. - Only the host fires
server_request. - Wake the TUI with
agent:submit; emittingagent_submissionis kept for compatibility.
Lua components
Replace or wrap the TUI component for an event.
local panto = require("panto")
panto.ext.on("render_tool_details", function(event)
if event.tool_name ~= "skill" then return end
local inner = event:get_component()
local handle = event:set_component({
render = function(self, width)
local lines = inner:render(width)
lines[#lines + 1] = "▸ running skill"
return lines
end,
})
end)| Call | Returns |
|---|---|
| event:get_component() | Opaque native component on a render event; nil on a custom event with no component set |
| native:render(width) | Fresh copy of its lines |
| event:set_component(table) | Handle for your Lua component |
| event:set_component(native) | Nothing; reinstalls the native one |
Component methods are unavailable on typed-payload lifecycle events (session_start, turn_*, command_*, tool_result, and server_request); calling either raises event '<name>' has no component. On custom events, including session_ready, session_end, and suspend, get_component() returns nil until a component is set, and set_component() works.
handleInput.Render errors show a diagnostic row. Keep component callbacks synchronous; don’t await while rendering or handling input. Mounting, unmounting, changing input modes, or calling a component handle’s set_pinned from render() raises a Lua error.
Standalone mounts
panto.ext.mount(component, "above_input") places a Lua component above the input, independently of transcript events. It must implement render(self, width); multiple mounts appear in insertion order.
| Mount handle | Does |
|---|---|
| invalidate() | Request a redraw after component state changes. |
| alive() | False after unmount or runtime teardown. |
| unmount() | Remove this mount; safe to call more than once. Raises if called from render(). |
Exclusive input modes
panto.ext.input_mode { keys, on_exit? } temporarily takes TUI input for a custom interaction. Key and exit callbacks run synchronously and must not yield.
| Field or method | Behavior |
|---|---|
| keys | Nonempty array of { key, desc?, run }; key is a canonical chord, optional desc appears in the keybind legend, and run(handle) handles it. |
| on_exit(reason, message) | Optional callback after input ownership is released; reasons are closed, turn, modal, session, teardown, or error. |
| close(), alive() | Close the mode; query whether it is still active. |
Malformed specs raise a Lua error: keys must be a nonempty array of entries with unique, non-reserved canonical chords and function run callbacks; desc must be a string or nil, and on_exit a function or nil. nil, reason is returned for a valid mode that cannot take input, such as when another mode, modal, turn, session switch, or missing TUI prevents it.
Chords use canonical lowercase modifiers in ctrl+alt+shift+ order followed by a printable character or named key: space, enter, escape, tab, backspace, delete, up, down, left, right, home, end, page_up, page_down, or f1–f12. Shift applies to named keys (including space), not printable letters. Only Ctrl+C, Ctrl+D, Ctrl+Z, Ctrl+H, Ctrl+I, Ctrl+J, and Ctrl+M are reserved from modes. Undeclared keys and pastes are swallowed; Ctrl+C/D/Z retain their host behavior.
A mode may return nil, reason instead of a handle: mode, modal, turn, switching, or no_tui. An input mode exits as closed only when its handle's close() is called; Esc is dispatched like any other key and swallowed unless the mode declares an escape chord. It exits as turn when a turn begins, modal when a modal opens, session when switching sessions, teardown when the runtime ends, or error if a key callback fails; the error message is passed as message.
Protocol providers
Implement a model wire protocol in Lua, then select it in your provider config.
| Config table | Setting |
|---|---|
| [providers.<name>] | protocol = "demo" |
local panto = require("panto")
return {
name = "demo-provider",
activate = function()
panto.ext.register_protocol {
name = "demo",
effort_levels = { { label = "quick", detail = "short response" } },
open = function(request)
local sent = false
return {
next = function(self)
if sent then return { type = "done" } end
sent = true
return { type = "text_delta", text = "Hello from " .. request.model }
end,
close = function(self) end,
}
end,
}
end,
}register_protocol fields
protocol value.The request
| Field | Contents |
|---|---|
| purpose, protocol, provider, model | Strings |
| effort | Selected level; absent when empty |
| context | Durable session key for upstream state |
| request_controls | max_tokens, temperature, top_p, stop_sequences, parallel_tool_calls |
| system_prompt | Effective system content |
| history_transcript | Compaction-aware history; excludes a trailing plain user prompt on turns, empty for other purposes |
| messages | Active non-system messages as { role, blocks }; blocks hold text, thinking, tool use (input_json string), tool results, system, media, compaction summaries |
| tools | { name, description, input_schema_json }; schema is a JSON string |
| notify() | Call when a queued event is ready |
The stream
request.notify()).| Event type | Fields |
|---|---|
| text_delta, thinking_delta | Required text |
| tool_call | Required id, name, input_json (string) |
| done | Optional usage = { input, output, cache_read, cache_write, reasoning } (non-negative integers) and termination = { reason, provider_reason, stop_sequence } |
| error | Optional message and kind: rate_limit, overloaded, authentication_failed, oauth_org_not_allowed, billing_error, invalid_request, terminal, model_not_found, server_error, transport, context_overflow |
Protocol callbacks run synchronously; never block or await.
Agents, jobs & submissions
Steer the live agent, or build your own.
The host agent
The host agent is the live agent: the child inside a child-agent tool, otherwise the session agent. Queue a string or content blocks:
local agent = require("panto").ext.agent
agent:submit("Please check the generated file") -- at the next safe tool boundary
agent:submit("Summarize the result", { followup = true }) -- after the turn endsIt’s borrowed: you can’t close it or change its tools or config. It is unavailable under panto serve.
Owned agents
| Constructor | Notes |
|---|---|
| panto.agent { ... } | Config inline or as config (table or resolve_model result); optional store, conversation, session_id (looked up in the store). To resume history, also pass the loaded conversation. |
| panto.conversation() | New conversation |
| panto.null_store() | No persistence: drops writes; lists are empty and loads return nil. |
| panto.file_system_jsonl_store { dir, metadata? } | File store; both fields are strings. |
| Method | Does |
|---|---|
| run(prompt) | Stream for a text prompt |
| proceed() | Continue when the last non-system message is from the user; otherwise raises NothingToProceed |
| run_async(opts) | Background job (below) |
| submit(input, opts?) | Host agent only |
| register_tool, tools, set_tools | Add, read, or replace tools (add and replace: owned agents only); don’t edit or persist underscore tags |
| set_config | Owned agents only; applies next turn |
| set_system_prompt, add_system_message | System content |
| set_message_metadata(i, value) | Owned agents only; JSON-object table or nil to clear. Updates the stored message; errors if it cannot be durably annotated. |
| compact, session_id, conversation | Compact; read id or conversation |
| Stream method | Does |
|---|---|
| next(), events() | Next event, or a for-loop iterator |
| reopen() | Retry after failure |
| last_failure() | { error_name, status_code?, response } after a failed next(); otherwise nil |
Streams close when collected. After an agent adopts a conversation, use the agent’s conversation view instead of the original userdata.
For built-in OpenAI Chat and Anthropic Messages streams, EOF counts as truncated only if neither completion signal arrived: finish_reason or [DONE] for Chat, stop_reason or message_stop for Messages. Such malformed streams fail as ProviderStreamMalformed (and may be retried by the configured policy); partial assistant turns are not committed as complete.
Background jobs: run_async
job:close() returns.Returns job, err. The job’s methods are listed below.
| Job method | Does |
|---|---|
| next_event() | Next queued event, or nil while the queue is empty |
| result() | Nil while running; when settled, status (completed, failed, cancelled) plus optional text, error, tool_calls |
| request_cancel(), close() | Request cancellation; close() joins the worker and releases the job |
Jobs refuse borrowed agents, a second live job, and agents with register_tool handlers (use a shared tool source). Conversation edits are refused while a job is live.
For Agent, Decider, and Extractor async calls, optional wake_fd must be a valid non-blocking FD. Keep it open with unchanged flags until job:close() returns; wake bytes are best-effort and may be dropped. Agent jobs signal published events; Decider and Extractor jobs signal settlement. close() cancels if needed and joins the worker. Call job:close() before decider:close() or extractor:close(); closing either owner while an async job remains open raises a Lua error. HTTP cancellation starts only after connection and TLS setup; DNS, TCP connect, and TLS handshake are not cancellable, and api_timeout_ms starts after setup, so close() may remain blocked there.
Deciders
The v0.6.0 panto.decider { llm = config } (or another backend arm) replaces the v0.5.0 panto.evaluator/:evaluate API. It answers named structured questions in one request. Choose exactly one config arm: llm, system_one, typesafe, cloudflare, or openai_decisions. The llm arm takes the existing agent config; extension-protocol configs fail with UnsupportedProviderForDecider when deciding. HTTP arms require a non-empty model and accept optional api_key, extra_headers (an array of { name, value }), retry ({ max_attempts?, initial_delay_ms?, max_delay_ms?, multiplier?, jitter? }), and api_timeout_ms; set api_key = false for explicit no-auth. System One and TypeSafe take exactly one of base_url or full_url; the former appends /v1/systemone, the latter is an exact endpoint. Cloudflare requires url; OpenAI Decisions requires base_url and appends /decisions.
| Request or result | Shape |
|---|---|
| decider:decide { input, questions } | Input is nonempty text, { text }, a JSON string in { json }, or ordered text/image parts in { parts } (nonempty text strings and { image = { name, bytes, media_type? } } records). Image names must be unique and nonempty; bytes are raw, not URLs, and magic bytes override the optional media_type hint. JSON must have an object or array root; it is not encoded from a Lua table. |
| Question types | Nonempty dense 1-based array; each question needs a unique nonempty id, nonempty instructions, and type: binary, choice, score, or box. Binary questions accept optional string true_criteria and false_criteria describing what qualifies as true or false. Choices use 2–255 options with nonempty unique ids in a 1-based options array of { id, description? }; scores use 2–10 nonempty labels in a 1-based scale array of { label, description? }. A box names its target image with image, or omits it when there is only one. Cloudflare and OpenAI Decisions reject box questions; System One needs a single image. |
| Answers | Each result.answers entry is keyed by question id and has type (binary, choice, score, box, or refusal): binary has probability; choice has winning option, plus a 1-based options array of { id, probability } and confidence; score has score (a zero-based, possibly fractional position from 0 through #scale - 1), plus a 1-based scale array of { index, label, probability } (each index is zero-based) and confidence; box has top, bottom, left, right as 0–1 fractions from the image's top-left and nullable confidence. Refusals have no other fields. |
| Other result fields | usage is optional; raw is always a string containing the provider response (empty for the LLM backend). There is no model field. |
| Async | decide_async returns a job when started, or nil, err if it cannot start (for example, an invalid request or unavailable owner). job:result() is nil while running, then returns a table with status completed (and result), failed (and err), or cancelled (and err = "cancelled"). There is no pending status. Use request_cancel() and close() to cancel and release it. |
decide() blocks while the request runs and returns its result; invalid requests and provider or transport failures raise Lua errors. Use decide_async() for a job instead.
local panto = require("panto")
local decider = panto.decider {
system_one = { base_url = "https://example.test", model = "decision-model", api_key = false },
}
local result = decider:decide {
input = "A customer requested a refund after 40 days.",
questions = {
{ id = "eligible", type = "binary", instructions = "Is the refund eligible?" },
{ id = "severity", type = "score", instructions = "Rate the issue.",
scale = { { label = "low" }, { label = "medium" }, { label = "high" } } },
},
}
print(result.answers.eligible.probability)
decider:close()Extractors
panto.extractor { cloudflare = config } (or llm = config) extracts named, typed values from text. Choose exactly one cloudflare or llm config arm. Cloudflare requires url and a non-empty model; other HTTP settings follow the Decider conventions. Extension-protocol configs in the LLM arm fail with UnsupportedProviderForExtractor.
| Request or result | Shape |
|---|---|
| extractor:extract { input, fields, reference_date?, images? } | input is text and may be empty only if an image is supplied. Optional reference_date is an ISO date anchoring dates written without a year. fields is a nonempty dense array with unique id and kind: text, number, date, time, datetime, duration, list, estimate, or forecast. IDs are 1–64 ASCII characters, starting with a letter or underscore and continuing with letters, digits, _, ., or -. images is a separate optional array of { name, bytes, media_type? } records with unique nonempty names, accepted by both backends; bytes are raw, not URLs, and magic bytes override the optional media_type hint. |
| Field options | instructions, unit, integer, decimals, items, min, max, step, scale, horizon, interval, and null_threshold. |
| Answers | result.answers is an ordered array of { id, kind, value, guess, confidence, p_null, span, spans, distribution, steps }; unset values are nil. usage is optional; raw is the provider response string (empty for LLM), and truncated is a boolean. |
| Spans | span and each item in spans are { start, end } with zero-based, half-open offsets into the input. The provider's non-ASCII indexing unit is unverified; do not assume offsets are UTF-8 bytes. |
| Uncertainty | distribution is a nullable table for estimates; steps is a 1-based array of such tables for forecasts. Each has nullable numeric mean, median, p10, p90, p_below, and p_above fields. |
| Value shapes | value and guess are strings for text/date/time/datetime, numbers for number and estimate, { value, unit } (number plus string) for duration, and 1-based arrays for list and forecast values. List elements follow the field's items kind; forecast elements are numbers. Unset fields are nil. |
| Async | extract_async follows the Decider job contract and returns nil, err if it cannot start. The LLM backend returns values only and does not support estimate or forecast. |
extract() blocks while the request runs and returns its result; invalid requests and provider or transport failures raise Lua errors. Use extract_async() for a job instead.
local extractor = require("panto").extractor {
cloudflare = { url = "https://example.test/extract", model = "extractor", api_key = false },
}
local result = extractor:extract {
input = "Invoice INV-2291 totals $1080.",
fields = { { id = "invoice", kind = "text" }, { id = "total", kind = "number", unit = "USD" } },
}
print(result.answers[1].id, result.answers[1].value)
extractor:close()Sessions & transcripts
Read stored sessions and build conversations.
| Store method | Returns |
|---|---|
| list(), list_bounded(limit) | Session infos; limit may be { limit = n } |
| resolve(id), latest() | Session info, or nil |
| load(id) | Conversation, or nil |
| load_messages(id, opts) | Owned conversation, or nil; opts limit, max_bytes, from_end, role, metadata_key |
| Session info | id, created, modified, message_count, last_user_message, api_style, base_url, model, reasoning, optional string metadata |
A custom store is a Lua table passed as store. Implement the methods above plus:
createappend_messagesupdate_message_metadata- Both load methods must return an owned conversation or nil.
| Conversation method | Notes |
|---|---|
| add_system_message, replace_system_message, add_user_message, add_user_blocks, add_assistant_message, add_compaction_summary | Append or edit |
| messages() | Copy of roles, blocks, optional assistant usage |
| transcript() | Labelled plain text, compaction-aware, no system messages |
| len() | Message count |
| message_metadata(i), set_message_metadata(i, v) | 1-based; JSON-object table or nil. Setter is in memory only; nil clears. |
Block type | text, thinking, tool_use, tool_result, media |
Media data | Raw bytes when writing, plus optional media_type; base64 in snapshots. Decode snapshot data before writing it back. |
Text, JSON, highlighting & themes
Utilities for rendering, JSON, themes, and host queries.
| Function | Does |
|---|---|
| panto.text.width(s) | Display columns |
| panto.text.truncate(s, n) | Prefix of at most n columns |
| panto.text.wrap(s, n) | Array of wrapped rows |
| s, n | Text helpers require a string and a non-negative integer width. |
| panto.ext.json.decode(s) | Any JSON value; null becomes json.null. Invalid JSON raises an error. |
| panto.ext.json.encode(v) | JSON with sorted keys; {} encodes as an object |
| panto.ext.json.array(t) | Marks and returns t so {} encodes as [] (decoded arrays already are) |
| panto.ext.json.null | JSON null sentinel |
| panto.ext.highlight(body, slug?) | Foreground-only ANSI syntax-highlighting for source by language or extension (rs); without a recognized slug, panto detects from the first line and leaves unknown text unchanged. The exact slug markdown instead uses MD4C to render styled display Markdown with markup removed while preserving source line breaks; md remains source highlighting. A Markdown rendering failure raises a Lua error. |
| panto.ext.style() | Copy of the active theme |
| panto.ext.style(name) | Copy of a named theme |
| panto.ext.style(name, values) | Register a theme: every role as a string, optional reset = "\27[0m"; returns no value |
- Source highlighting ends with
ESC[39m; reapply your foreground color after it. Markdown display rendering closes an OSC 8 link only if one was emitted and appendsESC[0monly if styled output was emitted. - Call
style()while rendering to follow live theme changes. - Re-registering identical theme values is harmless; different values under the same name fail.
Session and models
These host queries return nil, error when unavailable.
| Call | Returns |
|---|---|
| session_info() | { session_id, session_dir, model, reasoning } |
| models {} | Current model and reasoning; providers with name, style, models count |
| models { provider?, query?, limit? } | { matches, truncated }; each match has ref, wire_model, reasoning_default, context_window?, max_tokens?. Default limit 10, capped at 50. |
| models { model = "p:alias" } | found, ref, wire_model, reasoning_default, reasoning_levels, optional limits |
| resolve_model { model, reasoning?, tool_choice? } | Opaque config for panto.agent; exposes model, reasoning, style, wire_model, never credentials. tool_choice: auto, none, required, or { name }. |
| report_session_usage { model, usage } | true; adds non-negative integer input, output, cache_read, cache_write, reasoning tokens from outside work to session totals. Missing counts are zero. |
| usage_cost { model, usage } | Prices the same token-count shape as report_session_usage without recording it. Resolves model aliases to their wire-model price and returns unrounded USD; returns nil, error if a used category has no known price. |
| resume { id? | path?, keep_system_prompt? } | Request a switch to a stored session by catalog id/prefix or session-file path; exactly one of id and path is required. keep_system_prompt keeps the stored system prompt instead of reconciling it with configured prompts. Returns true when accepted or nil, err (resume: <reason>) when refused; malformed arguments raise a Lua error. |
resume is applied after the calling extension returns because it replaces the Lua runtime. Code after the call still runs against the outgoing session and must not await. It is refused mid-turn, while another switch is pending, for the current session, or when switching is unavailable (including print mode).
Extension state
panto.ext.state.get(path) and set(path, value) access persistent TOML-compatible values in state.toml, under extension_state, independently of config. Paths are dotted keys; prefix them with your extension name to avoid collisions. A missing get returns nil and setting nil deletes the value. set returns true on success; both calls return nil, error on failure. State updates use the persistent sibling lock file state.toml.lock; don’t remove it while panto may be writing. Available in interactive and print sessions and panto lua; unavailable in configuration hooks and panto serve (calls return nil, error with state: remembered state is unavailable in this context).
local state = require("panto").ext.state
state.set("my_extension.reader.wpm", 350)
local wpm = state.get("my_extension.reader.wpm")Config: panto.ext.config
Read your own settings from a plain copy of the merged raw config.toml, ready before evaluation or activation.
- Unknown keys are included.
${env:...}placeholders stay literal; literal values remain visible.- TOML dates and times become strings.
Layer and file rules: configuration.
Runtime & luarocks
panto embeds Lua 5.4.7 and LuaRocks 3.13.0, and installs luv 1.52.1 bound to its own event loop. Use that loop; don’t create another.
| Code | Can await? |
|---|---|
| Tool handlers | Yes |
Source evaluation, activate(), commands, ordinary events, components | Only when the loop is not running; keep live TUI callbacks synchronous |
| Server-request handlers, protocol callbacks | Never |
No other rocks ship; install libraries as dependencies or into panto’s tree.
- Every configured rock loads as an extension; a library that returns no valid entry logs a load failure each startup.
- Declare libraries in your extension’s rockspec to install them transitively, or use the command below.
panto lua -e 'arg[0]="luarocks"; require("luarocks.cmd").run_command("", { install = "luarocks.cmd.install" }, "luarocks.cmd.external", "install", "lpeg")'| Rock tree | Includes |
|---|---|
| <data home>/rocks/lua-5.4.7/ | Lua and libuv headers, rocks, metadata, LuaRocks configuration |
Interpreter arguments: the CLI reference.