Reference

Lua extensions

require("panto")

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:

{ name, activate }
A named entry whose activate() function performs registration when the entry survives policy and shadowing.
{ name, description, schema, handler }
Sugar form for a single tool entry. Activation registers it as a tool automatically.
{ ... , ... }
A list of entries or tool declarations returned from one file.

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" }.

model selectionlua
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() }
end

The 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:

LayerLocation
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:

panto.ext.dirslua
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 in activate(), 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:

.panto/tools/echo.lualua
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.

.panto/extensions/greet.lualua
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.

FamilyNames and payload fields
sessionsession_start (version, cwd, model), session_ready, session_end
turnturn_start, turn_interrupt, turn_end (all three carry reason and exit_requested; reason is nil on turn_start)
messagesuser_message (text), thinking/thinking_delta/thinking_complete (index, delta, text), assistant_text/assistant_text_delta/assistant_text_complete (index, delta, text)
toolstool_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)
othercommand_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.

.panto/extensions/redact.lualua
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.

.panto/extensions/skill-badge.lualua
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):

.panto/extensions/demo-provider.lualua
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:

extension submissionlua
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:

background joblua
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
end

job: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:

conversation inspectionlua
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.

tool handler awaitlua
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:

shellbash
panto extension update
panto lua -e 'arg[0]="luarocks"; require("luarocks.cmd").run_command("install","lpeg")'