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, slash commands, and UI-event handlers into the live session.

Overview

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.

Inside activate(), use local panto = require("panto") and the panto.ext table:

register_tool
Register a model-facing tool.
register_command
Register a slash command.
on / emit
Subscribe to transcript/UI lifecycle events or fire custom ones.

Loading extensions

panto scans both extensions/ and tools/ directories at each layer:

LayerLocation
base$XDG_DATA_HOME/panto/agent/{extensions,tools}
user$XDG_CONFIG_HOME/panto/{extensions,tools} · else ~/.config/panto/{extensions,tools}
project./.panto/{extensions,tools}

Additional sources can come from [extensions] paths and [extensions] rocks. Cross-layer precedence is project > user > base. Within one layer, canonical directories beat extra paths, which beat rocks. Identity is the returned entry's name, not the filename.

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.

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)
    return "echo: " .. input.message
  end,
}

Use register_tool inside activate() when you want one entry to register multiple tools or commands together.

register_command

Slash commands are typically registered from an explicit entry:

.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"
        io.write("\n[greet] hello, " .. who .. "!\n")
      end,
    }
  end,
}

Events: on & emit

panto.ext.on(name, handler) subscribes to transcript/UI lifecycle events. The handler receives an event userdata with snake-case methods get_component() and set_component(), plus payload fields such as tool_name, delta, text, id, input, output, cwd, and model.

Event familyNames
tooltool, tool_details, tool_delta, tool_call_complete, tool_result, tool_collapse
thinkingthinking, thinking_delta, thinking_complete
assistant textassistant_text, assistant_text_delta, assistant_text_complete
single-shotuser_message, session_start, compaction
customwhatever you emit via panto.ext.emit

A few payload fields are writable during dispatch: user_message.text, tool_call_complete.input (assign a Lua table; panto serializes it to JSON), and tool_result.output.

.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()
      e:set_component({
        render = function(self, width)
          local lines = inner:render(width)
          lines[#lines + 1] = "▸ running skill"
          return lines
        end,
      })
    end)
  end,
}

Lua components

A Lua component is a table passed to set_component(). The required method is render(self, width), which returns an array of strings. handleInput(self, data) is optional. Differential repaint is cache-derived automatically; you do not need to implement firstLineChanged yourself.

Runtime & luarocks

panto embeds a pinned Lua and luarocks runtime and ships luv as its core async dependency. Extra rocks may be loaded as extension sources via [extensions] rocks and installed with panto update, or added manually with panto lua.

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