# panto — verified content notes (from repo source) Source: https://code.tjp.lol/pantograph.git/ (cgit). Only document IMPLEMENTED features. Project self-describes as "Early". NOT implemented yet (DO NOT DOCUMENT): server mode, subagents, MCP, permission systems, AGENTS.md automation, skills, customizable /prompts, native (.so) extensions (planned "next"; Lua is what ships). Standard tools (read/write/edit/bash) ship as extensions. ## CLI invocation / subcommands (src/subcommand.zig — printHelp + dispatch, VERBATIM help text) panto Start a new conversation. panto --resume Resume the most recent conversation in this directory. panto --resume Resume the conversation whose id begins with . panto sessions List saved sessions for this directory. panto auth status Show configured auth sessions and login state. panto auth login Log in to an OAuth auth session (device flow). panto auth logout Forget a stored OAuth token. panto bootstrap [--force] Run the luarocks bootstrap and exit. panto lua [args...] Drop into the embedded Lua interpreter. panto help | --help | -h Show help. Details: - dispatch routes argv[1]; unknown/absent -> agent REPL. - `lua`: embedded standalone Lua interpreter (panto's lua.c build) with luarocks runtime bootstrap completed first, so `require("luarocks.*")` and the configured rocks tree work the same as in the agent process. argv rewritten so the interpreter sees `lua [...args]` (affects arg[0]). - `bootstrap`: runs the luarocks runtime bootstrap pipeline only, then exits before any agent loop. First-run setup on a fresh machine (downloads+compiles batteries, stages headers, materializes config); also good for CI/scripted installs. Idempotent: re-runs no-op fast. `--force`: wipe the per-Lua-version tree before reinstalling everything. Equivalent to deleting `$PANTO_HOME/rocks/lua-X.Y.Z/` by hand then running `panto bootstrap`. - `sessions`: lists sessions for the current working directory. One per line: ` messages`. short-id = first 8 hex chars of the session UUIDv7. created trimmed to `YYYY-MM-DD HH:MM`. "no sessions for " when empty. - `auth` (default action = status): - status: per configured auth session. api_key -> "resolved" OR "unresolved (key/env missing)" oauth_device -> "logged in (access expires in ~Nm)" OR "not logged in (run: panto auth login )" - login : OAuth device flow. Prints: "To authorize, open this URL in a browser: " and "enter the code: ". Runs secondary token exchange now if configured. api_key sessions: "'' is an api_key session; nothing to log in to". - logout : deletes the stored token set ("logged out of ''" / "no stored token for ''"). ## Config files (TOML, merged base -> user -> project) [from help text] - $XDG_DATA_HOME/panto/config.toml (base; auto-generated) - $XDG_CONFIG_HOME/panto/config.toml (user) - ./.panto/config.toml (project) Schema teaser (NEED config_file.zig for full schema): - [providers.] define providers - [defaults] model = ":" pick the default model - [tools] / [extensions] allow/deny globs gate tools & extensions - Model aliases (wire name, reasoning, max_tokens, pricing) live in models.toml. ## Environment variables [from help text] - OPENAI_API_KEY, ANTHROPIC_API_KEY — consumed by the default providers. - PANTO_SESSION_DIR — override base sessions dir. Default $XDG_DATA_HOME/panto/sessions or ~/.local/share/panto/sessions. - PANTO_HOME — override the runtime/rocks tree location. ## Slash commands (src/command.zig) - REPL treats any line beginning with `/` as a slash command (never sent to the model). - Parse: first whitespace-delimited word after `/` = name; trimmed remainder = args. - Unknown command -> error.CommandNotFound -> user-facing error message. - Builtin command seen: `/compact` (registers from its own module; compacts the conversation). - Lua extensions add commands: a script calls `panto.ext.register_command { name, description, handler }`, harvested by lua_runtime.zig; main.zig registers each. Lua-backed commands carry a lua_ref. - Tab-completion: planned (registry has list()). ## CONFIG FILE — full schema (src/config_file.zig, VERIFIED) ### Layers — 4 TOML files, merged lowest precedence first (NOTE: help text only lists 3; loader has 4) 1. base — $PANTO_HOME/config.toml OR ${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml (auto-generated by bootstrap) 2. user — ${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml 3. project — ./.panto/config.toml 4. local — ./.panto/config.local.toml (intended to be git-ignored; personal overrides on top of team project layer) Missing files are skipped silently. ### Merge semantics - Tables merge recursively; scalars AND arrays from a higher layer overwrite WHOLESALE (no array append). e.g. project `tools.deny = [...]` replaces the array entirely. - Tables accumulate: a provider defined only at base survives when project adds a different provider. ### `${...}` substitution (auth values) - `${env:VAR}` reads the environment; `${sibling}` reads another key in the SAME [auth.] section. - Unresolved reference (missing env var / sibling) → empty string. e.g. define `domain = "github.com"` then `device_code_url = "https://${domain}/login/device/code"`. ### [providers.] (networked provider; transport only) - style (required) — one of: openai_chat | anthropic_messages | openai_responses - base_url (required) — string - auth (required) — names the [auth.] session supplying the credential. (Clean break: provider-level api_key / api_key_env_var are NO LONGER accepted.) - prompt_cache (bool, default true) — anthropic_messages ONLY; one advancing cache_control breakpoint per request. Ignored for openai_chat. - [providers..extra_headers] — table of string header name=value, merged onto each model request. - A provider whose api_key auth resolves empty is DROPPED ("export the key or the provider disappears"). OAuth providers always survive (resolved at turn time / via `panto auth login`). ### [auth.] (named auth session; `type` inferred if omitted: client_id→oauth_device, key→api_key) api_key: - type = "api_key" (optional; inferred from `key`) - key = "${env:VAR}" (usually) or a literal — resolved eagerly; empty ⇒ session unresolved. oauth_device: - type = "oauth_device" (optional; inferred from `client_id`) - dialect = "token" (default) | "codex" - client_id (required) - device_code_url (required) - token_url (required) - device_poll_url (required ONLY for dialect="codex") - verification_url (optional) - scope (optional) - token_request_format = "form" (default) [enum panto.TokenRequestFormat] - redirect_uri (optional) - account_id_jwt_claim (optional) - secondary token exchange (flat keys; active when exchange_url present): exchange_url (enables exchange) exchange_method (default "GET") exchange_token_path (default "token") — JSON path to the token exchange_expires_path (optional) — JSON path to expiry exchange_base_url_path (optional) — JSON path to a dynamic base_url - arbitrary sibling keys allowed (e.g. `domain`) for use in ${...} templating. ### [defaults] - model = ":" — provider must resolve. Selection precedence: 1) future --model override, 2) defaults.model, 3) if exactly ONE provider resolved AND it has exactly ONE alias in models.toml, use that, 4) else error (no model selected). - Model ref format: exactly one colon, both halves non-empty (e.g. `anthropic:claude-sonnet-4-6`). ### [tools] / [extensions] — allow/deny glob policy (identical shape) - allow = [glob,...] deny = [glob,...] - Empty allow ⇒ allow-all (still subject to deny). Permitted iff matches ≥1 allow (or allow empty) AND no deny. - Same pattern in BOTH allow and deny (same kind) ⇒ hard error. - Names are dotted, e.g. std.read, std.write, std.edit, std.shell; globs like `std.*`. (Standard tools ship as extensions under the `std.` namespace and can be denied individually.) ### [compaction] - keep_verbatim = 0> — kept-suffix token budget (omitted ⇒ libpanto default). - model = ":" — optional override model used for compaction (provider must resolve). ### Model aliases live in models.toml (resolved separately). Knobs seen in buildProviderConfig: - common: wire `model` name, `reasoning` (ReasoningEffort, default .default), `max_tokens` (default 64000) - anthropic_messages extra: api_version (default "2023-06-01"), thinking (default disabled), effort (default medium), thinking_budget_tokens (default 32000), thinking_interleaved (default false) -> NEED models_toml.zig for the exact TOML table layout + key names. ### Model aliases live in models.toml (src/models_toml.zig, VERIFIED) Path: ${XDG_CONFIG_HOME:-$HOME/.config}/panto/models.toml. Missing file ⇒ empty registries (no error). A referenced alias with NO entry is NOT an error: the alias is used verbatim as the wire model name, with default knobs and unknown pricing (zero-config convenience). Entry table key: [.] — matches a [providers.] in config.toml; is the short name referenced as : (e.g. anthropic:sonnet). Keys: - model = wire model id sent to the API; defaults to if omitted. - max_tokens = per-request OUTPUT token cap; >0 else null (= provider/library default). - Pricing (all optional, USD per MILLION tokens; omitted = UNKNOWN/null, write `= 0` for known-zero): input = output = cache_read = cache_write = - openai_chat ONLY: reasoning = default | off | minimal | low | medium | high (default: default) - anthropic_messages ONLY: api_version = Anthropic-Version header; default "2023-06-01" thinking = disabled | enabled | adaptive (default: disabled) effort = low | medium | high | xhigh | max (default: medium) — only used when thinking = "adaptive" thinking_budget_tokens = max reasoning tokens for thinking="enabled"; default 32000; null ⇒ max_tokens − 1; ignored when adaptive/disabled thinking_interleaved = send interleaved-thinking beta header (default false); only honoured when thinking = "enabled" Example models.toml: [anthropic.sonnet] model = "claude-sonnet-4-20250514" max_tokens = 8192 thinking = "enabled" thinking_budget_tokens = 16000 input = 3.0 output = 15.0 cache_read = 0.3 cache_write = 3.75 [anthropic.opus] model = "claude-opus-4-8" thinking = "adaptive" effort = "high" [openai.gpt] model = "gpt-4o" input = 2.5 output = 10.0 ## LUA RUNTIME / LUAROCKS (src/manifest.zig + subcommand.zig, VERIFIED) - panto embeds a PINNED Lua + luarocks and ships "batteries" (rocks). The ONLY battery auto-installed is `luv` (libuv event loop) — the single runtime dependency. It drives panto's coroutine scheduler and gives extension authors one rich async I/O surface. - Bootstrap reconciles the installed rocks tree against the manifest on EVERY startup: installs missing, removes stale. The battery list is compiled into the binary; users do NOT configure it. - Extra rocks are the user's responsibility. Install via: panto lua -e 'arg[0]="luarocks"; require("luarocks.cmd").run_command(...)' (A higher-level `panto rocks install` is PLANNED — DO NOT document.) - Per-Lua-version rocks tree lives at $PANTO_HOME/rocks/lua-X.Y.Z/ (wiped by `panto bootstrap --force`). - luarocks MAJOR.MINOR.PATCH-REV version strings (e.g. "1.52.1-0") passed straight to luarocks install. ## EXAMPLES INVENTORY (repo /examples) - examples/extensions/echo.lua (632) — example panto Lua EXTENSION <-- fetch - examples/extensions/greet.lua (719) — example panto Lua EXTENSION <-- fetch - examples/tools/ (dir) — example tools <-- fetch tree - examples/simple-agent.lua (3110) — libpanto Lua SDK agent (NOT panto-ext; for libpanto, skip-ish) - examples/simple-agent-go/ — libpanto Go SDK agent (NOT panto-ext) ## LUA EXTENSION SURFACE — verified from examples Module: `local panto = require("panto")` (available inside extensions AND in `panto lua`). ### panto.ext.register_tool { name, description, schema, handler } (examples/extensions/echo.lua) - name : tool name (string) the model calls. - description : shown to the model. - schema : JSON-Schema table — { type="object", properties={...}, required={...} }. - handler : function(input) -> string. `input` is the decoded arguments table; return a string result. echo example: handler = function(input) return "echo: " .. input.message end ### panto.ext.register_command { name, description, handler } (examples/extensions/greet.lua) - Registers a `/`-slash command in the REPL. name is WITHOUT the leading `/`. - handler = function(args) ... end. `args` is the trimmed text after the command name ("" if none). Handlers run SYNCHRONOUSLY and act by side effect (e.g. io.write); the RETURN VALUE IS IGNORED. greet example: `/greet` or `/greet ` -> io.write("\n[greet] hello, "..who.."!\n") - A Lua command name colliding with a builtin (e.g. compact) is rejected (DuplicateCommand). ### Where extensions live (from greet.lua header comment) - Project: `./.panto/extensions/` - User: the user config dir's `extensions/` dir (i.e. ${XDG_CONFIG_HOME:-~/.config}/panto/extensions/). - Drop a `.lua` file there; it's loaded at startup. Gate via [extensions] allow/deny globs in config.toml. ### Runtime - The embedded interpreter is the panto `lua.c` standalone (Lua + luarocks). `luv` (libuv) is always available for async I/O; panto's coroutine scheduler drives the libuv loop. - `panto lua [args...]` drops into the same interpreter for scripting/experiments; `panto bootstrap` prepares the rocks tree. - NOTE: I have register_tool + register_command verified. The file src/lua_event_bridge.zig (event hooks) is NOT yet read — DO NOT invent event APIs (on_turn, hooks, etc.) until verified. ## KEYBINDINGS — verified from src/tui_input.zig (the input decoder; P1 scope, "minimal but correct") The REPL input layer turns raw stdin into keys. Keys it recognises TODAY (document only these): Editing / text: - Printable characters (full UTF-8, multi-byte) -> insert - Enter (Return) -> submit the message - Shift+Enter -> insert a newline (terminal-dependent, see below) - Backspace -> delete char before cursor - Delete -> delete char under/after cursor - Tab -> tab - Bracketed paste -> pasted text inserted literally as one run (not interpreted key-by-key) Cursor / navigation (decoded with modifiers): - Left / Right -> move cursor by character - Ctrl+Left / Ctrl+Right -> move by word (the "word-motion path") - Home / End -> line start / end - Up / Down -> arrow keys (decoded; move within a multi-line draft) - Page Up / Page Down -> decoded - Alt+ -> decoded (ESC-prefixed alt forms) Control: - Ctrl+C -> interrupt / quit - Ctrl+D -> EOF / exit - Esc -> escape - Ctrl+ generally (0x01–0x1a) -> decoded as ctrl+a..z Shift+Enter detail (genuinely useful doc): in the bare legacy protocol Enter and Shift+Enter both send `\r` and are indistinguishable. At startup panto negotiates the **Kitty keyboard protocol** (pushes flags 1|4 = disambiguate + report-alternates; deliberately NOT report-events) and queries the terminal; if confirmed it reads Shift+Enter as `CSI 13;2u`. Otherwise it falls back to xterm **modifyOtherKeys mode 2** (tmux/xterm), which sends `CSI 27;2;13~`. On terminals supporting NEITHER (e.g. macOS Terminal.app) the two stay identical and Enter submits — there's no newline binding there. (Ghostty maps shift+enter to a bare `\n`.) Deferred / negotiated-but-not-consumed (DO NOT present as features): key-release events, super/hyper modifiers, full Kitty disambiguation — modelled for later phases, not active. ## MAIN / STARTUP / TUI WIRING (src/main.zig, VERIFIED) — lots of new facts ### Agent-mode flags (the ONLY ones) - `--resume` → resume most recent session in this cwd. - `--resume ` → resume session whose id has prefix . - No flag → new session. Unknown args are tolerated/ignored (warn). - TUI REQUIRES an interactive tty ("panto's TUI requires an interactive terminal"). ### Builtin slash commands - ONLY builtin registered is from compaction.zig → `/compact` (manual compaction). Everything else is added by Lua extensions. (Do NOT invent /help, /model, etc. as slash commands.) ### LIVE keybindings added by main.zig (the interactive ones!) - Ctrl+C / Ctrl+D → clean exit (UserExit). - Ctrl+M → MODEL selector (runtime model picker overlay). - Ctrl+R → REASONING-effort selector (runtime reasoning picker overlay). Both are live-session only: a pick rebuilds the provider config and pushes it to the agent (setConfig); NOTHING is written back to config.toml. - Enter submits; Shift+Enter newline (see tui_input notes). Input box + footer + transcript differential render. ### Lua extension surface — EXPANDED (main.zig wiring, VERIFIED) - `require('panto')` is wired to the native `panto.so` module PLUS the CLI's `ext` subtable → `panto.ext`. - Lua runtime is ONE long-lived lua_State; module-global state persists across calls. Registers with the agent as a single ToolSource named `panto-lua`. - `luv` (libuv) scheduler is installed BEFORE extensions load, so tool handlers may YIELD (async I/O). - panto.ext.register_tool {…} → adds a model-callable tool (see echo.lua). - panto.ext.register_command {…} → adds a `/`-slash command (see greet.lua). - panto.ext.on(...) → registers a UI EVENT handler into the App's event bus, in registration order. Extensions can WRAP/REPLACE built-in TUI components this way (e.g. a `tool_details` handler replacing the default `tool (?)` component). Superseded overrides are released (ref + RenderCache freed). - panto.ext.emit(...) → a Lua call that drives the SAME event bus. (NOTE: exact event/kind names + on/emit signatures live in src/lua_event_bridge.zig — fetching now. Until verified, describe on/emit as the component-override/event surface and cite tool_details as the one named example from main.zig. Don't enumerate events I haven't seen.) ### Extension discovery (3 layers; src/extension_loader.discoverAndLoad) - base = $PANTO_HOME/agent - user = $XDG_CONFIG_HOME/panto (or $HOME/.config/panto) - project = ./.panto - Precedence: project shadows user shadows base. Tool-NAME collisions across surviving entries ABORT startup. - Gated by [extensions] and [tools] allow/deny policies from config.toml. - (greet.lua header: drop a .lua under .panto/extensions/ project, or the user config's extensions/ dir.) ### System prompt — a real config surface (system_prompt.zig, referenced by main.zig) - The agent's system prompt is sourced by convention from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project layers (base = $PANTO_HOME/agent, where bootstrap stages the bundled SYSTEM.md). SYSTEM.md replaces; APPEND_SYSTEM.md appends. Reconciled on resume without rewriting history. - Compaction system prompt = COMPACTION.md across layers (last wins; built-in default otherwise). ### Compaction - Automatic compaction is armed at startup. [compaction].keep_verbatim default = 20000 tokens. - `/compact` triggers it manually. [compaction].model can override the model used for compaction. ### Sessions / auth (confirm) - Sessions are per-cwd; created on demand. Resume by id-prefix or latest. "resumed session <8hex> ( messages)". - Per-turn auth resolution: api_key is a no-op; oauth_device refreshes/exchanges or runs an interactive device login before the turn. models.toml path = $XDG_CONFIG_HOME/.config/panto/models.toml. - Config error messages are friendly (e.g. "defaults.model must look like \"provider:model\"."). ## panto.ext EVENT SURFACE — VERIFIED (src/lua_event_bridge.zig) ### panto.ext.on(name, handler) — subscribe a Lua handler to a UI event - The handler participates in the SAME native EventBus the built-in TUI fires. Registration order matters. - handler = function(e) ... end. `e` is a bridged EVENT OBJECT, valid ONLY during the handler call (snapshot any field you need into a local — do NOT close over `e` and read it at render time). - Event object API: e.name -> the event name (string) e. -> read-only payload fields (nil when absent/empty, so you can branch: `if e.tool_name then ...`) e:getComponent() -> the current (native default) component, as an OPAQUE passthrough handle e:setComponent(c) -> replace the component for this boundary. `c` is EITHER that passthrough handle (pass-through / wrap) OR a Lua component table (replace). - Payload fields by event family (from pushPayloadField): tool : index(int), tool_name, id, delta, input, output (lifecycle: tool_delta has delta, tool_result has output, tool_details…) thinking : index, delta, text assistant_text : index, delta, text user_message : text session_start : version, cwd, model compaction : summary custom : (no structured fields — produced by emit) (VERIFIED event NAME used in tests: "tool". Lifecycle boundary names tool_delta/tool_result/tool_details appear in source comments. Present these as the event families; the canonical example is "tool".) ### A Lua COMPONENT is a table: { render = function(self, width) -> { "line1", "line2", ... } end, handleInput = function(self, data) ... end, -- optional firstLineChanged = ..., -- optional (cache-derived by default) invalidate = ... } -- optional - render() MUST return an array of strings; runs SYNCHRONOUSLY (may NOT yield — unlike tool handlers); each line is truncated to `width` columns; any error yields a safe "[lua component error: …]" fallback line (the frame never crashes). Empty array {} = zero lines (valid). ### Three override patterns (from the bridge's own tests): - pass-through native default: e:setComponent(e:getComponent()) - replace with a Lua component: e:setComponent({ render = function(self,w) return {"hi"} end }) - claim-by-name (the canonical extension shape): panto.ext.on("tool", function(e) if e.tool_name ~= "skill" then return end -- ignore everything else local name = e.tool_name -- snapshot at handler time e:setComponent({ render = function(self, w) return { "SKILL:"..name } end }) end) ### panto.ext.emit(name, data) — fire a custom event on the SAME bus - Runs native AND Lua handlers for `name`. `data` is currently surfaced only as an opaque `.custom` payload (structured marshalling is future). A component chosen from a bare emit is NOT auto-mounted yet. ## ALL CONTENT GATHERED — ready to build. (lua_runtime/extension_loader internals not needed beyond above.) ## KEY MODEL (src/tui_key.zig, VERIFIED) — what the P1 decoder currently handles Decoded keys the running TUI populates today (the model is bigger for future phases, but ONLY these are live): printable chars, enter, backspace, arrows (up/down/left/right), home, end, escape, Ctrl+C, Ctrl+D. Mods modelled: ctrl, alt, shift, super, hyper — but P1 decoder only ever sets ctrl (super/hyper need Kitty protocol; left false). Key events: press/repeat/release — most terminals only emit .press. => The keybindings page must document ONLY the live subset; note richer keys are planned, not shipped.