Reference
Lua extensions
panto's standard tools are Lua extensions, and your own extensions use the same loader. A source file returns one entry, one tool declaration, or a list of them; activation then registers tools, commands, providers, and UI handlers into the live session.
Overview
There is no global panto. In an extension, start with local panto = require("panto"). The module combines the native libpanto API with host-owned panto.ext and panto.text tables.
Extension source files are evaluated first and activated later. A file must return one of these shapes:
activate() function performs registration when the entry survives policy and shadowing.panto.ext provides register_tool, register_command, register_protocol, on, emit, run_foreground, highlight(body, language_slug?), style()/style(name)/style(name, values), json, dirs, agent, session_info, models, and resolve_model. session_info() returns the current session id, directory, model, and reasoning label, or nil, error when no session host is available. models {} describes providers; models { provider = ..., query = ..., limit = ... } returns matching model references; and models { model = ... } resolves one reference. The limit defaults to 10 and is capped at 50.
resolve_model { model = ..., reasoning = ..., tool_choice = ... } returns an opaque model configuration, or nil, error. Pass that value as config to panto.agent; credentials remain in the host. Tool choice can be "auto", "none", "required", or { name = "tool" }.
local panto = require("panto")
local function new_worker()
local cfg, err = panto.ext.resolve_model { model = "provider:alias" }
assert(cfg, err)
return panto.agent { config = cfg, store = panto.null_store() }
endThe native table also provides agents, streams, conversations, stores, and jobs; those are covered below. Duplicate tool, command, or protocol names in one runtime are errors.
Loading extensions
panto scans both extensions/ and tools/ at each layer; they contribute to one entry space rather than separate namespaces. The layers are ordered from lowest to highest precedence:
| Layer | Location |
|---|---|
| base | $XDG_DATA_HOME/panto/agent/{extensions,tools} · else ~/.local/share/panto/agent/{extensions,tools} |
| user | $XDG_CONFIG_HOME/panto/{extensions,tools} · else ~/.config/panto/{extensions,tools} |
| project | ./.panto/{extensions,tools} |
| local | ./.panto/local/{extensions,tools} |
Precedence is local > project > user > base. Within one layer, canonical directories beat extra [extensions] paths, which beat [extensions] rocks. Relative paths resolve against the project directory. A rock that cannot load is skipped. Files can be single .lua sources or directories with init.lua; the latter can require sibling modules. Symlinks are followed, and dotfiles or underscore-prefixed files are skipped.
Use panto.ext.dirs when an extension needs resources. It is plain data, published before sources are evaluated, and contains home when $HOME is available, project_root, and layers in lowest-to-highest order. Bundle runs expose one bundle layer and intentionally omit home:
return {
name = "list-resource-layers",
activate = function()
local panto = require("panto")
for _, layer in ipairs(panto.ext.dirs.layers) do
local skills = layer.dir .. "/skills"
print(layer.name, skills)
end
end,
}Match layers by name, not array position: an unresolvable layer is omitted. Derive resource paths from layer.dir, such as layer.dir .. "/skills". Do not read HOME or XDG_* yourself.
Eval is side-effect free
Every candidate file is evaluated before policy and shadowing are resolved, including entries that will later be denied. Put side effects inactivate(), not at top level.The Lua state lasts exactly as long as the session. /new, /resume, and /tree rebuild it and run evaluation and activation again, so module-global state is per-session. Persist anything that must survive a session switch outside the VM, except registered themes: theme registrations are process-local and survive Lua VM teardown and session switches.
register_tool
The simplest way to define a tool is to return the sugar form directly:
return {
name = "echo",
description = "Echo back the given message.",
schema = {
type = "object",
properties = { message = { type = "string" } },
required = { "message" },
},
handler = function(input, context)
return context.tool_name .. ": " .. input.message
end,
}The explicit form is panto.ext.register_tool { name, description, schema, handler }; all four fields are required. The handler receives the decoded JSON object and a context table with tool_call_id and tool_name. A handler may return a string, or { text = "...", attachments = { { media_type = "...", data = "..." } } }. The text and attachments are sent back as result parts; media_type is optional. Return other types are rejected.
Tool handlers run as coroutines, so they may await libuv work; see Runtime & luarocks. Keep registration inside activate() when one entry registers several things.
register_command
Register a slash command with name, description, and handler. The handler receives the trimmed remainder of the slash line. A string return value becomes command output; nil and other return values are ignored.
return {
name = "greet",
activate = function()
local panto = require("panto")
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,
}panto.ext.run_foreground { argv = { ... } } is available only inside a TUI slash-command handler. It temporarily restores the terminal, runs a child with inherited stdio, redraws the TUI, and returns a result table such as { success = true, exit_code = 0 } or one containing signal, stopped, or unknown. The argument array must be non-empty, dense, and made of NUL-free strings.
Events: on & emit
panto.ext.on(name, handler) subscribes in registration order. The handler receives one event userdata with event.name, event:get_component(), event:set_component(x), and the fields for that event. Optional string fields can appear as nil when their value is empty.
| Family | Names and payload fields |
|---|---|
| session | session_start (version, cwd, model), session_ready, session_end |
| turn | turn_start, turn_interrupt, turn_end (all three carry reason and exit_requested; reason is nil on turn_start) |
| messages | user_message (text), thinking/thinking_delta/thinking_complete (index, delta, text), assistant_text/assistant_text_delta/assistant_text_complete (index, delta, text) |
| tools | tool_dispatch_start, tool, tool_details, tool_delta, tool_output_delta, tool_call_complete, tool_result, tool_collapse (index, tool_name, id, input, output, result_ok, collapsed as applicable) |
| other | command_start (command_name, args), command_end (command_name, args), compaction (summary); agent_submission is the wake event for queued submissions |
For tool events, tool starts with index; tool_details adds tool_name and id; tool_delta adds argument delta and accumulated input; tool_call_complete carries id and final input; tool_result carries id, output, and result_ok; and tool_collapse carries the current input/output. Tool events also expose collapsed. Thinking and assistant-text complete events omit delta; delta events include the chunk and accumulated text.
session_ready, session_end, command_start, command_end, and the turn events are notifications without a default component. tool_dispatch_start and tool_output_delta are shipped tool lifecycle events. tool_call_complete ends the call; tool_result arrives later.
Three fields are writable during dispatch: user_message.text, tool_call_complete.input (assign a Lua table), and tool_result.output. The last writer wins, and later handlers read earlier overrides. Assigning another field is an error.
return {
name = "redact",
activate = function()
local panto = require("panto")
panto.ext.on("tool_result", function(e)
if e.tool_name == "shell" then e.output = "(redacted)" end
end)
end,
}panto.ext.emit(name, data) fires a custom event on the same bus, but data is currently ignored. A custom event exposes only event.name. panto.ext.agent:submit already invokes the host submission notifier; explicit emit("agent_submission") remains compatibility support.
Handlers are synchronous
on handlers cannot yield or await today. Errors are logged and swallowed so a broken handler does not abort dispatch. The async event-handler design note is not a shipped API.Lua components
A Lua component is a table passed to set_component(). Its required method is render(self, width), which returns either a bare string or an array whose entries are converted through lua_tolstring (numeric entries are accepted). handleInput(self, data) is optional and receives raw input bytes; Lua components have no key-release opt-in. There are no Lua fit or measure hooks. Render failures produce a dim fallback diagnostic row instead of aborting the TUI.
Chunks are not terminal rows. The engine splits, sanitizes, wraps, pads, replays styles, and adds a trailing reset for the terminal width. Components can use width to arrange their own content, but should return content chunks rather than pre-fitting rows. The shipped tool extensions use agent/tools/_std.lua's install_renderer helper to pair tool registration with the tool lifecycle events; its underscore prefix keeps this shared library out of discovery.
return {
name = "skill-badge",
activate = function()
local panto = require("panto")
panto.ext.on("tool_details", function(e)
if e.tool_name ~= "skill" then return end
local inner = e:get_component()
local handle = e:set_component({
render = function(self, width)
local lines = inner:render(width)
lines[#lines + 1] = "▸ running skill"
return lines
end,
})
assert(handle:alive())
end)
end,
}The handle returned when setting a Lua table has invalidate(), set_pinned(bool), and alive(). Call invalidate() after changing state that the component will render; otherwise differential repaint may skip it. set_pinned(true) keeps it at the live transcript tail. A native component returned by get_component() is an opaque passthrough with render(width), which lets a wrapper render the default and add chunks. The returned lines table is a fresh Lua copy.
Protocol providers
panto.ext.register_protocol lets an extension implement a provider wire protocol. It requires name and open(request); it can also declare up to 32 effort_levels ({ label, optional detail }) and context callbacks cancel_turn, reset_context, release_context, and close. Configure routing with [providers.<name>] protocol = "<registered-name>"; a configured extension-protocol model then routes turns to this registration.
open receives the protocol, provider, model, purpose, selected effort (when non-empty), durable session context, effective system prompt, canonical history_transcript, active messages, and registered tools. It also receives notify(), which wakes a pending stream. Return a stream table with next(self):
return {
name = "demo-provider",
activate = function()
local panto = require("panto")
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", usage = { output = 1 } } end
sent = true
return { type = "text_delta", text = "Hello from " .. request.model }
end,
close = function(self) end,
}
end,
}
end,
}Stream events are text_delta, tool_call (with id, name, and JSON-string input_json), done, or error. A done event may include non-negative integer usage fields input, output, cache_read, cache_write, and reasoning. An error event has optional kind and message fields and can classify a rate limit, overload, authentication failure, invalid request, terminal failure, missing model, server error, transport failure, or context overflow. Return nil from next when no data is ready, then call request.notify() when data arrives. stream:cancel_turn() is an abandonment fallback and runs only when the stream is unfinished; stream:close() runs unconditionally at stream teardown.
Protocol callbacks run on the Lua owner loop under protected calls. They must not block or await. The durable context string is stable across turns: use it to key upstream sessions. reset_context follows compaction, release_context follows detach/deinit, and registration close runs at runtime teardown.
Agents, jobs & submissions
The native panto module exposes panto.agent { ... }, panto.conversation(), panto.null_store(), and panto.file_system_jsonl_store { dir = "..." }. Agents provide run, run_async, submit, register_tool, tools, set_tools, set_config, system-prompt and compaction methods, session_id(), and conversation(). Streams provide next(), events(), and reopen().
panto.ext.agent is the borrowed live session agent. It is lane-resolved: a tool running for a child agent sees that child agent, while ordinary extension code sees the session agent. Queue a host submission with steering timing by default, or use followup = true to wait for the active turn to finish:
local panto = require("panto")
panto.ext.agent:submit("Please check the generated file")
panto.ext.agent:submit("Summarize the result", { followup = true })
panto.ext.emit("agent_submission")A steering submission is injected at the next safe post-tool boundary; a follow-up waits for the current turn. submit accepts a string or a content-block array, invokes the host submission notifier automatically, and is available only on panto.ext.agent, not on an owned agent. Submissions are dispatched through the host input paths rather than being echoed merely when queued.
For independent background turns, use agent:run_async. It returns job, err and accepts prompt or blocks, optional metadata, dispatch_tools, and an integer wake_fd. Poll buffered events and then the settled result:
local panto = require("panto")
local function inspect(worker) -- an owned Agent, not panto.ext.agent
local job, err = worker:run_async {
prompt = "Inspect the cache",
metadata = { source = "extension" },
}
assert(job, err)
local ev = job:next_event()
while ev do
print(ev.type)
ev = job:next_event()
end
local result = job:result() -- nil until settled
job:close() -- idempotent; joins the pump
return result
endjob:next_event() returns one event or nil; job:result() returns a table with a status (completed, failed, or cancelled) and optional text, error, or tool_calls fields once settled. Use job:request_cancel() for two-phase cancellation and job:close() when finished; close also runs from garbage collection. Jobs use one OS thread and never touch the Lua state. They refuse borrowed agents, a second live job, and agents with local register_tool handlers; use a shared tool source instead. While a job is live, conversation-mutating methods and most agent operations are refused.
Sessions & transcripts
Session stores expose list(), list_bounded(limit) (or { limit = n }), resolve(id), latest(), load(id), and load_messages(id, opts). The bounded loader accepts limit, max_bytes, from_end, role, and metadata_key. A session-info table contains id, exact created and modified timestamps, optional metadata, message_count, last_user_message, api_style, base_url, model, and reasoning. A custom store is a Lua table passed as store to panto.agent.
A loaded conversation is an owned userdata. Conversation snapshots may include optional assistant usage data. It supports system and user/assistant message builders, compaction summaries, messages(), transcript(), len(), and message metadata. transcript() returns the labelled plain-text, compaction-aware transcript with system messages omitted. messages() returns a copied snapshot whose blocks can be fed back to the builders:
local conv = store:load(session_id)
if conv then
print(conv:transcript())
for i, message in ipairs(conv:messages()) do
print(i, message.role, #message.blocks)
end
end
local recent = store:load_messages(session_id, {
limit = 20, from_end = true, role = "user",
})Conversation metadata is 1-based: conv:message_metadata(i) reads a table or nil, and conv:set_message_metadata(i, value) changes the in-memory conversation. agent:set_message_metadata(i, value) is the durable path: it updates the live message and its persisted record. Message tree id/parent_id is not exposed to Lua.
[extensions] controls discovery and activation with allow, deny, paths, and rocks. The data home contains the staged agent/ tree, OAuth auth/ files, and versioned rocks/lua-5.4.7/ trees with Lua headers, libuv headers, pure-Lua and C rocks, metadata, and LuaRocks configuration.
Runtime & luarocks
panto embeds Lua and luarocks, stages the native panto.so, and ships exactly one battery: luv. libuv 1.52.1 is vendored into the panto executable and luv is built against that host library, so there is one process loop. Do not create a separate libuv loop; require("luv") uses panto's loop.
Tool handlers are the coroutine entrypoint that may await libuv work. Components, on handlers, and protocol callbacks are synchronous. Extension activation and slash-command handlers can use the driven entrypoint before the loop is running, but a yield in a running synchronous entrypoint fails with yielded, but this entrypoint must not await; its caller needs the result now.
local uv = require("luv")
local function await_stat(path)
local co = assert(coroutine.running())
local result
uv.fs_stat(path, function(err, stat)
result = { err, stat }
assert(coroutine.resume(co))
end)
coroutine.yield()
return table.unpack(result)
end
-- Call await_stat from a registered tool handler coroutine.
The loop owner is the only thread allowed to touch Lua. Off-loop provider and tool work is dispatched to it. Use panto.ext.json.decode and encode for JSON without a rock; json.array(t) marks an empty table as an array, and json.null preserves JSON null in object fields and array slots. Decode accepts any top-level JSON value.
Terminal width has one authority: panto.text.width(s), panto.text.truncate(s, n), and panto.text.wrap(s, n). They require a string and a non-negative integral width. Other rocks are your responsibility; list them under [extensions] rocks and run panto extension update, or install manually:
panto extension update
panto lua -e 'arg[0]="luarocks"; require("luarocks.cmd").run_command("install","lpeg")'