From b350e4dee2412e450b3844061f5c1bc2ec995923 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 6 Jul 2026 15:00:37 -0600 Subject: catch up with latest panto cli developments --- cli.html | 9 +++------ configuration.html | 53 ++++++++++++++------------------------------------- index.html | 16 +++++++--------- keybindings.html | 4 ++-- lua.html | 56 +++++++++++++++++++++++++++++++----------------------- 5 files changed, 58 insertions(+), 80 deletions(-) diff --git a/cli.html b/cli.html index 0b14e57..80780f6 100644 --- a/cli.html +++ b/cli.html @@ -20,13 +20,10 @@
Source
- +
-

Reference

Command line

panto dispatches on its first argument. A handful of subcommands (lua, bootstrap, sessions, models, auth, help) short out before the agent loop; anything else — or nothing — starts the agent in the current directory.

CommandDoes
pantoStart a new conversation in the current directory.
panto --resume [<id>]Resume the most recent session, or one by id prefix.
panto sessionsList saved sessions for this directory.
panto models syncFetch models.dev and rebuild the base models.toml.
panto auth …Inspect and manage auth sessions (status / login / logout).
panto bootstrap [--force]Run the luarocks bootstrap and exit.
panto lua [args…]Drop into the embedded Lua interpreter.
panto helpPrint the usage summary (also --help, -h).

Starting a session

pantoStart a new conversation in the current directory.
panto --resume [<id>]Resume a previous session.

With no flag, panto opens a fresh session. --resume on its own reattaches the most recent session in this directory; --resume <id> resumes the session whose id begins with <id>. Unknown arguments are tolerated (and ignored with a warning), so passing flags panto doesn’t recognise won’t stop it from starting.

Needs a terminal

The UI runs in raw mode and requires an interactive tty on stdin/stdout. Piping input or redirecting output will refuse with an error rather than half-start.

panto sessions

panto sessionsList saved sessions for the current directory.

Sessions are grouped per working directory. Each line is the short id (the first eight hex characters of the session’s UUIDv7), the creation time trimmed to the minute, and the message count:

panto · sessions
$ panto sessions
7f3a9c2b 2026-06-12 09:41 24 messages
b81e0d44 2026-06-11 17:03 6 messages
a0c7f190 2026-06-10 22:18 41 messages

When a directory has no sessions yet, panto prints no sessions for <cwd>. Use any unambiguous id prefix from this list with panto --resume.

panto models

panto models syncFetch models.dev and rebuild the base models.toml.

sync is the only action. It fetches the models.dev catalog, walks the providers in your merged [providers] config, and for each one cross-checks the catalog against the models that provider actually serves — then writes the result to the base-layer models.toml ($PANTO_HOME/models.toml, else $XDG_DATA_HOME/panto/models.toml). Aliases, wire names, context windows, token caps, and pricing come along for the ride. A provider maps onto a catalog entry via model_catalog_name (falling back to the provider name); providers with no matching entry — or no usable synced models — are skipped.

panto · models sync
$ panto models sync
synced 42 model(s) across 3 provider(s) to ~/.local/share/panto/models.toml

Because it writes the base layer, your own user/project models.toml entries always layer on top and win. Re-run it whenever you want to refresh the catalog after providers ship new models.

panto auth

Manage the credentials behind your providers. With no action, panto auth defaults to status.

panto auth status
List every configured auth session and its state. api_key sessions read resolved or unresolved (key/env missing); oauth_device sessions show whether you’re logged in and roughly when the access token expires.
panto auth login <name>
Run the OAuth device flow for the named session. panto prints a verification URL and a user code, then stores (and, if configured, exchanges) the token. An api_key session has nothing to log in to.
panto auth logout <name>
Forget the stored token set for the named session.
panto · auth status
$ panto auth status
anthropic_api api_key resolved
openai_api api_key unresolved (key/env missing)
github_copilot oauth_device logged in (access expires in ~58m)

Auth sessions themselves are declared in config under [auth.<name>]. The provider that uses a session names it with auth = "<name>".

panto bootstrap

panto bootstrap [--force]Run the luarocks bootstrap, then exit.

The bootstrap pipeline prepares the embedded Lua runtime: it stages headers and the base agent tree, materialises the default config, and installs panto’s pinned batteries under $PANTO_HOME. The agent runs the very same pipeline on startup, so you rarely need this by hand — it’s here for first-run setup on a clean machine and for scripted / CI installs. Repeat runs no-op quickly.

--force
Wipe the per-Lua-version rocks tree ($PANTO_HOME/rocks/lua-X.Y.Z/) before reinstalling everything from scratch.

panto lua

panto lua [args…]Drop into the embedded Lua interpreter.

This is panto’s bundled Lua standalone (the same lua.c the agent embeds), with the luarocks runtime already bootstrapped — so require("luarocks.*") and any rocks installed under $PANTO_HOME resolve exactly as they do inside a session. Arguments after lua are passed straight through to the interpreter. Use it to experiment, or to drive luarocks to install extra rocks for your extensions:

shellbash
# run a script with panto’s Lua + rocks tree
-panto lua my-script.lua
-
-# install an extra rock into panto’s tree via the embedded luarocks
-panto lua -e 'arg[0]="luarocks"; require("luarocks.cmd").run_command("install","lpeg")'

Slash commands

Inside a session, any line that begins with / is a slash command rather than a prompt — it is handled locally and never sent to the model. The first word (after the slash) is the command name; the trimmed remainder is passed as its arguments.

/compactbuiltin
Compact the conversation now — summarise older turns to reclaim context while keeping a verbatim recent tail. (Compaction also runs automatically; see [compaction].)

/compact is the only built-in. Every other slash command comes from a Lua extension via panto.ext.register_command — see Lua extensions → register_command. An unrecognised command reports an error instead of reaching the model.

Environment

panto reads a few environment variables — for credentials and for locating its files. Configuration values can also pull from the environment via ${env:VAR} substitution (see Configuration).

VariableEffect
ANTHROPIC_API_KEYConsumed by the default Anthropic provider.
OPENAI_API_KEYConsumed by the default OpenAI provider.
PANTO_HOMEOverride the runtime / rocks-tree location. Also where the base config.toml and agent tree are staged.
PANTO_SESSION_DIROverride the base sessions directory. Defaults to $XDG_DATA_HOME/panto/sessions (or ~/.local/share/panto/sessions).
XDG_CONFIG_HOMELocates the user config: $XDG_CONFIG_HOME/panto/ (else ~/.config/panto/).
XDG_DATA_HOMELocates the base config + data: $XDG_DATA_HOME/panto/ (else ~/.local/share/panto/).
+

Reference

Command line

panto dispatches on its first argument. Some names are true subcommands (sessions, models, auth, bootstrap, update, lua, help); the flag forms start the agent itself, either interactively or in one-shot print mode.

CommandDoes
pantoStart a new interactive conversation in the current directory.
panto --resume [<id>]Resume the most recent session, or a specific session by id prefix.
panto -cAlias for bare --resume.
panto -p [prompt]Run one non-interactive turn, print assistant text, exit 0/1.
panto sessionsList saved sessions for this directory.
panto models syncFetch models.dev and rebuild the base models.toml.
panto auth …Show auth status, log in, or log out.
panto bootstrap [--force]Prepare the bundled Lua / luarocks runtime and exit.
panto updateInstall or refresh every rock listed in extensions.rocks.
panto lua [args…]Run the embedded Lua interpreter.
panto --versionPrint the panto version.

Starting a session

pantoStart a fresh interactive conversation.
panto --resume [<id>]Resume the latest or a specific saved session.
panto -p, --print [<prompt>]Run one turn without the TUI.

Interactive mode requires a tty on stdin and stdout. Print mode does not: the prompt can come from the flag argument or from piped stdin, and the command prints only the assistant text before exiting.

-c, --continue
Alias for bare --resume.
-m, --model <provider:alias>
Override the model for this run. A bare alias also works when it is unique across providers.
--effort <level>
Override the reasoning level for this run. This beats both models.toml knobs and [defaults] reasoning.
--no-extensions
Skip the Lua runtime entirely. The core still starts, but because the shipped tools are extensions the agent runs with no tools.

Unknown arguments are errors now

Agent-mode flag parsing is strict. An unrecognized argument prints an error and exits instead of being ignored.

panto sessions

panto sessionsList sessions for the current directory.

Sessions are stored per working directory. The listing is newest-modified first and prints the shortest unambiguous id prefix, the modified timestamp, message count, model, and last user message:

panto · sessions
$ panto sessions
ID MODIFIED MSGS MODEL LAST MESSAGE
0197c2a4 2026-07-01 14:32 18 sonnet add json mode to stats
0197bfe1 2026-06-30 09:11 6 gpt-5 clean up the parser errors
~/.local/share/panto/sessions/--Users-travis-Code-ledger--

If there are no sessions yet, panto prints no sessions for <cwd> and then the directory it would use.

panto models

panto models syncFetch models.dev and rebuild the base models.toml.

The sync command looks at the providers that resolved from your layered config, maps each one to a models.dev catalog entry, optionally intersects that with the provider's own live /models listing, and writes the result to the base-layer models.toml at $XDG_DATA_HOME/panto/models.toml (or ~/.local/share/panto/models.toml).

panto · models sync
$ panto models sync
synced 42 model(s) across 3 provider(s) to ~/.local/share/panto/models.toml

Your user and project models.toml layers still sit on top and win.

panto auth

With no action, panto auth defaults to status.

panto auth status
Show every configured auth session. API-key sessions report resolved or unresolved (key/env missing); OAuth sessions report whether they are logged in and when the current access token expires.
panto auth login <name>
Run the OAuth device flow for the named session and persist the resulting token set.
panto auth logout <name>
Delete the stored token set for the named session.

panto bootstrap

panto bootstrap [--force]Run the runtime bootstrap pipeline, then exit.

Bootstrap stages the base agent tree, creates the generated base config as needed, and prepares the versioned luarocks tree under the data home. The normal agent startup path runs the same bootstrap automatically.

panto updateInstall or refresh the rocks listed in extensions.rocks.

panto update is the explicit “go hit luarocks again” command. Startup only installs missing rocks; changing a version pin or forcing a re-resolve is what update is for.

panto lua

panto lua [args…]Run the embedded Lua interpreter with panto's runtime wired in.

This is the same embedded Lua build panto uses internally, with package paths and luarocks configured the same way a session sees them. It is handy both for experiments and for driving luarocks manually:

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

Slash commands

Lines beginning with / are handled locally inside the TUI and never sent to the model.

Built in
/help, /quit, /model, /reasoning, /new, /resume, /status, and /compact.
Extension-defined
Lua extensions can register more slash commands with panto.ext.register_command.

Environment

VariableEffect
ANTHROPIC_API_KEYConsumed by the default Anthropic provider.
OPENAI_API_KEYConsumed by the default OpenAI provider.
PANTO_DEBUGWhen non-zero, route std.log output to <data home>/debug/<session>.log instead of the terminal.
PANTO_SESSION_DIROverride the base sessions directory. panto still appends one encoded subdirectory per working directory.
XDG_CONFIG_HOMELocates the user config and user extensions directories.
XDG_DATA_HOMELocates the data home: base config, base models, sessions, auth cache, agent tree, and rocks.
diff --git a/configuration.html b/configuration.html index 1823478..48f5ee5 100644 --- a/configuration.html +++ b/configuration.html @@ -20,59 +20,34 @@
Source
- +
-

Reference

Configuration

TOML

panto is configured by layered config.toml files, plus a separate models.toml for model aliases and pricing. Define providers, pick a default model, and gate tools and extensions — everything you specify, nothing you don’t.

Files & layers

Four files are read and merged, lowest precedence first. Missing files are skipped silently.

LayerPathRole
base$PANTO_HOME/config.toml · else $XDG_DATA_HOME/panto/config.tomlAuto-generated by bootstrap. Lowest precedence.
user$XDG_CONFIG_HOME/panto/config.toml · else ~/.config/panto/config.tomlYour personal, machine-wide settings.
project./.panto/config.tomlChecked-in, per-project settings.
local./.panto/config.local.tomlPersonal per-project overrides — git-ignore this. Highest precedence.

Merge semantics: tables merge recursively, but scalars and arrays from a higher layer overwrite wholesale. A project file that sets tools.deny replaces the array entirely — it doesn’t append to a user-level list. Tables accumulate, so a provider defined only at the base layer survives even when a higher layer adds another.

config.tomltoml
# ~/.config/panto/config.toml  (user layer)
+

Reference

Configuration

TOML

panto reads layered config.toml files for providers, auth, defaults, TUI settings, and extension policy. Model aliases and pricing live beside them in a separate layered models.toml.

Files & layers

Four config.toml layers are merged, lowest precedence first. Missing files are skipped.

LayerPathRole
base$XDG_DATA_HOME/panto/config.toml · else ~/.local/share/panto/config.tomlAuto-generated defaults.
user$XDG_CONFIG_HOME/panto/config.toml · else ~/.config/panto/config.tomlMachine-wide personal settings.
project./.panto/config.tomlChecked-in project defaults.
local./.panto/config.local.tomlPersonal per-project overrides.

Merge rule of thumb: tables merge recursively; scalars and arrays from a higher layer replace lower-layer values. One important exception is [extensions]: its allow/deny rules and source lists accumulate across layers so policy and extra extension sources can be refined instead of erased.

config.tomltoml
[defaults]
+model = "anthropic:sonnet"
+reasoning = "high"
 
-[defaults]
-model = "anthropic:sonnet"          # <provider>:<alias>
+[tui]
+editor = "code -w"
+tools_collapsed = false
 
 [providers.anthropic]
 style    = "anthropic_messages"
 base_url = "https://api.anthropic.com"
-auth     = "anthropic_api"          # draws its key from [auth.anthropic_api]
+auth     = "anthropic_api"
 
 [auth.anthropic_api]
-key = "${env:ANTHROPIC_API_KEY}"    # resolved at load
-
-[tools]
-deny = ["std.bash"]                 # allow every tool except the shell

The local layer

./.panto/config.local.toml is meant to be git-ignored: apply your own overrides on top of the checked-in project layer without committing them.

[providers.<name>]

A provider is pure transport: an API shape and a base URL, plus the name of the auth session that supplies its credential. The chosen name is what you reference on the left of a provider:alias model.

stylestringrequired
The wire protocol: anthropic_messages, openai_chat, or openai_responses.
dialect"codex"
A sub-dialect for styles that share a protocol family. Only style = "openai_responses" accepts it today, with the single value "codex" (the Codex Responses variant).
base_urlstringrequired
The API base URL requests are sent to.
authstringrequired
Names the [auth.<name>] session that supplies this provider’s credential. (Provider-level api_key is no longer accepted — auth lives in one place.)
prompt_cachebooldefault true
anthropic_messages only. Place one advancing cache_control breakpoint on each request. Ignored for other styles.
extra_headerstable
A table of static string headers merged onto every model request (see below).
model_catalog_namestring
Maps this provider onto a models.dev catalog key (e.g. "github-copilot") for panto models sync. Defaults to the provider name.
config.tomltoml
[providers.copilot]
+key = "${env:ANTHROPIC_API_KEY}"

[providers.<name>]

A provider describes transport only: API shape, base URL, auth session, and a few request-level knobs. The provider name is the left half of a provider:alias model reference.

stylestringrequired
One of anthropic_messages, openai_chat, or openai_responses.
dialectstring
For openai_responses, the only current dialect is "codex".
base_urlstringrequired
API base URL.
authstringrequired
Names the [auth.<name>] session used for this provider.
prompt_cachebool
Anthropic only. Controls the advancing cache_control breakpoint.
extra_headerstable
Static request headers merged onto every request.
model_catalog_namestring
Optional models.dev provider key for panto models sync.
config.tomltoml
[providers.copilot]
 style    = "openai_chat"
 base_url = "https://api.individual.githubcopilot.com"
 auth     = "github_copilot"
 
 [providers.copilot.extra_headers]
 Copilot-Integration-Id = "vscode-chat"
-X-Initiator            = "user"

Unresolved providers vanish

If a provider’s api_key auth session resolves to empty (its env var isn’t set), the provider is dropped — export the key to bring it back. OAuth providers always survive and resolve at turn time.

[auth.<name>]

A named auth session supplies a credential. Its type is inferred when omitted: a key implies api_key; a client_id implies oauth_device.

Substitution

Every auth value supports ${…} substitution. ${env:VAR} reads the environment; ${name} reads another key in the same section (handy for a shared domain). An unresolved reference becomes the empty string.

API-key sessions

keystringrequired
The API key — usually "${env:VAR}", but a literal works too. Resolved eagerly at load; an empty result leaves the session unresolved.
type"api_key"
Optional; inferred from the presence of key.

OAuth device sessions

client_idstringrequired
The OAuth client id.
device_code_urlstringrequired
Endpoint that issues the device + user codes.
token_urlstringrequired
Endpoint polled for the access token.
dialect"token" | "codex"default token
Flow dialect. codex additionally requires device_poll_url.
scopestring
Requested OAuth scopes.
verification_urlstring
Override the URL shown to the user.
device_poll_urlstring
Distinct poll endpoint (required for codex).
token_request_format"form" | "json"default form
How the token request body is encoded.
redirect_uri / account_id_jwt_claimstring
Optional flow details.
exchange_urlstring
Enables an optional secondary token exchange. Companions: exchange_method (default GET), exchange_token_path (default token), exchange_expires_path, exchange_base_url_path — JSON paths into the exchange response.
config.tomltoml
[auth.github_copilot]
-client_id       = "Iv1.b507a08c87ecfe98"
-domain          = "github.com"          # an ordinary sibling key…
-device_code_url = "https://${domain}/login/device/code"   # …reused here
-token_url       = "https://${domain}/login/oauth/access_token"
-scope           = "read:user"
-
-# optional secondary token exchange
-exchange_url           = "https://api.${domain}/copilot_internal/v2/token"
-exchange_expires_path  = "expires_at"
-exchange_base_url_path = "endpoints.api"

[defaults]

modelstring
The default model, as "<provider>:<alias>" (exactly one colon, both halves non-empty). The provider must resolve.

Model selection falls through in order: an explicit override (a future --model), then defaults.model, then — if exactly one provider resolved and it has exactly one alias in models.toml — that one. Otherwise panto asks you to set a default.

Tools & extensions

[tools] and [extensions] share the same shape: allow- and deny-lists of glob patterns over dotted names (the standard tools live under the std. namespace — std.read, std.write, std.edit, std.bash).

allowstring[]
Glob patterns. Empty means allow-all (still subject to deny).
denystring[]
Glob patterns. A name is denied if it matches any deny pattern.

A name is permitted when it matches at least one allow pattern (or allow is empty) and matches no deny pattern.

config.tomltoml
[tools]
-allow = ["std.*"]          # only the standard tools
-deny  = ["std.bash"]       # …but never the shell

Conflicts are fatal

The same pattern in both allow and deny for one kind is a hard error — panto refuses to start rather than guess.

[compaction]

panto compacts long conversations automatically; this section tunes it. The manual /compact command uses the same settings.

keep_verbatimintdefault 20000
Token budget for the recent tail kept verbatim when compacting.
modelstring
Optional provider:alias override for the model that writes the summary. Defaults to the active chat model.

models.toml

Model aliases live in $XDG_CONFIG_HOME/panto/models.toml, separate from config.toml. Each entry is keyed [<provider>.<alias>] — the provider matches a [providers.<name>], the alias is the short name you reference. A referenced alias with no entry isn’t an error: it’s used verbatim as the wire name with default knobs.

models.toml is layered exactly like config.toml (base → user → project → local). The base layer is auto-generated — panto models sync fetches the catalog and writes it — so your user and project entries always layer on top and win.

Common keys

modelstring
Wire model id sent to the API. Defaults to the alias.
context_windowint
Total input context window, used for the footer’s context-usage display. Optional.
max_tokensint
Per-request output token cap.
input / output / cache_read / cache_writefloat
Pricing in USD per million tokens. All optional; omitted means unknown.

openai_chat only

reasoningstringdefault default
One of default, off, minimal, low, medium, high.

anthropic_messages only

thinkingstringdefault disabled
disabled, enabled, or adaptive.
effortstringdefault medium
low · medium · high · xhigh · max. Used when thinking = "adaptive".
thinking_budget_tokensintdefault 32000
Reasoning-token budget when thinking = "enabled".
thinking_interleavedbooldefault false
Send the interleaved-thinking beta header (honoured when thinking = "enabled").
api_versionstringdefault 2023-06-01
The anthropic-version header.
models.tomltoml
# ~/.config/panto/models.toml
-
-[anthropic.sonnet]
-model      = "claude-sonnet-4-20250514"   # wire name; defaults to the alias
-max_tokens = 8192
-thinking   = "enabled"
-thinking_budget_tokens = 16000
-input  = 3.0        # USD per million tokens
-output = 15.0
-cache_read  = 0.30
-cache_write = 3.75
-
-[openai.gpt]
-model     = "gpt-4o"
-reasoning = "medium"
-input  = 2.5
-output = 10.0

System prompt

The agent’s system prompt is sourced by convention from Markdown files across the same base → user → project layers (base is $PANTO_HOME/agent, where bootstrap stages the bundled default):

SYSTEM.md
Replaces the system prompt for that layer.
APPEND_SYSTEM.md
Appends to it — for small, additive house rules.
COMPACTION.md
Overrides the summary prompt used during compaction (last layer wins; a built-in default applies otherwise).

On resume, prompt changes are reconciled without rewriting the existing conversation.

- +X-Initiator = "user"

[auth.<name>]

Auth sessions are named, reusable credential sources. If type is omitted, panto infers it from the fields present: key implies api_key; client_id implies oauth_device.

Substitution

Every auth value supports ${...} substitution. ${env:VAR} reads the environment; ${name} reads another key in the same auth block. Unresolved substitutions become the empty string.

API-key sessions

keystringrequired
Usually "${env:VAR}". Resolved eagerly at load time.

OAuth device sessions

client_id / device_code_url / token_urlstringrequired
Core device-flow endpoints.
dialecttoken | codex
codex additionally requires device_poll_url.
scope / verification_url / redirect_uristring
Optional flow details.
exchange_…string
Optional secondary token exchange after device login.

[defaults]

modelstring
Default model as "<provider>:<alias>".
reasoningstring
Session-default reasoning label — the same names the Ctrl+R picker shows.

Model selection falls through in this order: --model, then [defaults] model, then the single-provider/single-alias convenience case. Reasoning falls through similarly: --effort beats a per-alias setting in models.toml, which beats [defaults] reasoning.

[tui]

editorstring
Overrides $VISUAL / $EDITOR for Ctrl+G.
tools_collapsedbool
Initial state of the global tool-output collapse toggle.

Extensions policy & sources

[extensions] governs every Lua-authored capability, including the shipped standard tools. The names you match are extension entry names such as std.read, std.write, std.edit, and std.shell.

allow / denystring[]
Glob rules over entry names. The default is allow; later, more specific rules win, and deny wins an exact tie.
pathsstring[]
Extra directories to scan for extension sources.
rocksstring[]
Installed rock specs to load as extension sources. panto update installs or refreshes them.
config.tomltoml
[extensions]
+deny  = ["std.shell"]
+paths = ["./dev/panto-ext"]
+rocks = ["panto-agent 1.4.2-1"]

[compaction]

keep_verbatimint
Token budget for the recent tail kept verbatim during compaction.
modelstring
Optional provider:alias override for the compaction model.

models.toml

models.toml is layered just like config.toml: base at $XDG_DATA_HOME/panto/models.toml, then user, project, and ./.panto/models.local.toml. Each entry is keyed [<provider>.<alias>]. If an alias is missing entirely, panto simply uses the alias itself as the wire model name.

Common keys

modelstring
Wire model id sent to the provider API. Defaults to the alias.
context_window / max_tokensint
Context metadata for the UI and a per-request output cap.
input / output / cache_read / cache_writefloat
Pricing in USD per million tokens.

OpenAI-style knobs

reasoningstring
Applies to openai_chat and openai_responses: default, off, minimal, low, medium, or high.

Anthropic-style knobs

thinkingstring
disabled, enabled, or adaptive.
effortstring
low, medium, high, xhigh, or max.
thinking_budget_tokens / thinking_interleaved / api_versionint | bool | string
Manual-thinking budget, interleaved-thinking beta header, and Anthropic API version.

System prompt

Prompt files are layered beside the extension tree: base under $XDG_DATA_HOME/panto/agent/, then user, then project. The conventional files are SYSTEM.md, APPEND_SYSTEM.md, and COMPACTION.md.

+
diff --git a/index.html b/index.html index f8ef07f..526e2e2 100644 --- a/index.html +++ b/index.html @@ -20,17 +20,15 @@
Source
- +
-

Getting started

panto

terminal agentziglua

panto is a small terminal coding agent built on libpanto. A minimal system prompt, a tool set you can swap or switch off, native binaries from Zig, extensions in Lua. This is the operator's reference — flags, configuration, key bindings, and the Lua surface.

Overview

You run panto inside a project directory and talk to a model in a terminal UI. The agent reads and edits files and runs commands through tools — and even the standard tools (read, write, edit, bash) ship as extensions, so any of them can be denied individually.

There are no permission prompts and no hidden machinery: providers are declared in a TOML file, the model is chosen by a provider:alias reference, and anything beyond the core is added as a Lua extension. The rest of these pages document each of those surfaces in turn.

Install

panto builds from source with Zig. Clone the repository and run the Zig build; the result is a single native binary.

shellbash
git clone https://code.tjp.lol/pantograph.git
+

Getting started

panto

terminal agentziglua

panto is a terminal coding agent with a small Zig core and a Lua extension surface. Run it interactively in a project directory, or use -p/--print for one-shot non-interactive turns. These pages cover the current CLI, configuration layers, key bindings, and extension API.

Overview

The default entry point is just panto: it opens the TUI in the current directory, selects a model from your layered config, and lets the agent work through tools. The shipped file tools are themselves Lua extensions, so the same loader that powers your own add-ons also powers std.read, std.write, std.edit, and std.shell.

The first argument also dispatches subcommands. panto sessions, panto auth, panto models sync, panto bootstrap, panto update, and panto lua all short-circuit before the chat loop; panto -p runs a single non-interactive turn and exits.

Install

panto builds from source with Zig. Clone the repository and build the binary:

shellbash
git clone https://code.tjp.lol/pantograph.git
 cd pantograph
-zig build            # native binary at zig-out/bin/panto

On first use, run the one-time bootstrap. It stages the embedded Lua runtime and installs panto’s pinned luarocks batteries (just luv, the libuv event loop) under $PANTO_HOME:

shellbash
panto bootstrap        # prepare the rocks tree (idempotent)

Where things live

By default panto keeps its runtime and rocks tree in $PANTO_HOME (falling back to $XDG_DATA_HOME/panto), and reads configuration from a layered set of TOML files. See Configuration → Files & layers.

First run

Run panto with no arguments inside a project to start a fresh conversation. The agent loads your config, selects the default model, and opens the terminal UI. Type a message and press Enter to send it.

panto · first run
$ export ANTHROPIC_API_KEY=sk-ant-…
$ cd ~/code/ledger && panto
 
panto v0.1.0
cwd: ~/code/ledger
 
add a --json flag to the stats command
 
I’ll wire a --json branch into the stats command. Let me read it first.
read src/stats.zig
pub fn run(args: Args) !void { - const summary = try compute(args); - try printHuman(summary); -}
edit src/stats.zig (1 edit)
- try printHuman(summary); -+ if (args.json) try printJson(summary) else try printHuman(summary);
 
Done — stats --json now prints a machine-readable summary.
 
anthropic:sonnet   18.4k ctx   2.1k tok   $0.03

panto’s UI needs an interactive terminal (a tty). Sessions are saved per directory, so you can pick up where you left off with panto --resume. The full command surface — resuming, listing sessions, auth, the embedded Lua interpreter — is on the Command line page, and every key is listed under Key bindings.

Authenticate

A provider draws its credential from a named auth session. There are two kinds. The simplest is an API key from the environment — the default OpenAI and Anthropic providers read OPENAI_API_KEY and ANTHROPIC_API_KEY directly:

shellbash
export ANTHROPIC_API_KEY=sk-ant-…
-panto                  # the anthropic provider is now live

The second is an OAuth device flow, for providers that authenticate interactively. Log in once and panto stores (and refreshes) the token for you:

shellbash
panto auth login github_copilot
-panto auth status      # show every session and its login state

If a provider seems missing

An api_key session whose environment variable is empty resolves to nothing, and any provider using it is silently dropped (export the key to bring it back). OAuth providers always survive and resolve at turn time. Auth sessions are declared under [auth] in config.
+zig build # native binary at zig-out/bin/panto

The first real run bootstraps panto's bundled Lua runtime automatically, so most users can go straight to panto. The explicit bootstrap command is still useful for CI, fresh-machine setup, or forcing the rocks tree to exist before you start a session:

shellbash
panto bootstrap
+# or: panto bootstrap --force

Where things live

The data home is $XDG_DATA_HOME/panto (or ~/.local/share/panto when XDG_DATA_HOME is unset). User config lives under $XDG_CONFIG_HOME/panto (or ~/.config/panto). Project-local overrides live in ./.panto/.

First run

Start an interactive session with no arguments:

shellbash
cd ~/code/ledger
+panto

Useful first-day flags:

panto --resume
Reattach the most recent conversation in this directory. --resume <id> resumes a specific session by id prefix; -c / --continue is an alias for bare --resume.
panto -p "prompt"
Run one turn non-interactively, print only the assistant text, and exit. The prompt may also come from piped stdin.
panto -m openai:gpt --effort high
Override the model and reasoning level for just this run.
panto --no-extensions
Skip the Lua runtime entirely. This disables shipped tools too, but it is a useful escape hatch when an extension breaks startup.

The TUI requires an interactive terminal. If you only want a single answer in a script or CI job, use -p instead of trying to pipe the full-screen UI.

Authenticate

Providers draw credentials from named auth sessions in config.toml. API-key sessions usually resolve from the environment:

shellbash
export ANTHROPIC_API_KEY=sk-ant-…
+panto auth status

OAuth device sessions are logged in explicitly once, then cached on disk:

shellbash
panto auth login github_copilot
+panto auth status

If a provider seems missing

An api_key auth session that resolves to the empty string leaves its provider unavailable, so exporting the missing environment variable is often enough to make it appear. OAuth providers stay configured and resolve at use time.
diff --git a/keybindings.html b/keybindings.html index 09739d8..747db1f 100644 --- a/keybindings.html +++ b/keybindings.html @@ -20,9 +20,9 @@
Source
- +
-

Reference

Key bindings

TUI input

The session UI reads raw keystrokes and turns them into editing actions. These are the keys it recognises today; panto negotiates richer terminal protocols at startup so the important ones (notably Shift+Enter) work wherever the terminal allows.

Editing & cursor

The input box is a multi-line editor. Printable keys insert text (full Unicode), and the usual motion keys move the cursor.

KeyAction
any printable keyInsert the character (multi-byte UTF-8 supported).
/ Move the cursor one character.
Ctrl+ / Ctrl+
Alt+ / Alt+ · Alt+B / Alt+F
Move the cursor by word (terminals vary which form they send).
Ctrl+A / Ctrl+EJump to the start / end of the current line.
Home / EndJump to the very start / end of the whole input.
BackspaceDelete the character before the cursor.
DeleteDelete the character under the cursor.
Ctrl+W · Alt+Backspace · Ctrl+BackspaceDelete the previous word.
Ctrl+UDelete from the cursor back to the start of the line.
pastePasted text is inserted literally as one run (bracketed paste), not interpreted key-by-key.

Submit & newline

KeyAction
EnterSend the message to the model.
Shift+EnterInsert a newline instead of sending (terminal permitting — see below).

Whether Shift+Enter inserts a newline depends on your terminal, because in the bare legacy protocol it is indistinguishable from plain Enter. panto works around this where it can — see Terminal protocol.

Selectors & control

Control keys switch the model and reasoning effort mid-session, collapse tool output, hand off to your editor, interrupt a running turn, and exit. The selectors, tool-collapse, and the Esc interrupt stay responsive even while the agent is mid-turn.

KeyAction
Ctrl+MOpen the model selector.
Ctrl+ROpen the reasoning-effort selector.
Ctrl+OCollapse / expand all tool-call output.
Ctrl+GEdit the current draft in $EDITOR, then read it back.
EscInterrupt the running agent turn; dismiss an open selector.
Ctrl+C / Ctrl+DExit cleanly.

Selectors are live-only

Picking a model (Ctrl+M) or reasoning level (Ctrl+R) rebuilds the active provider config and applies it to the running agent for this session — nothing is written back to config.toml.

Terminal protocol

Two TUI behaviours depend on the terminal’s keyboard protocol. panto negotiates the best available at startup and degrades gracefully.

Shift + Enter

In the bare legacy protocol, Enter and Shift+Enter send the same byte. To tell them apart, panto pushes the Kitty keyboard protocol (disambiguate + report-alternates) and queries the terminal; if that’s unavailable it falls back to xterm’s modifyOtherKeys mode (which tmux and xterm honour).

TerminalShift + Enter
Kitty, Ghostty, foot (Kitty protocol)Newline — fully supported.
xterm, tmux (modifyOtherKeys)Newline — via the fallback.
macOS Terminal.app (neither)Indistinguishable from Enter — submits.

No newline in Terminal.app

On terminals that support neither protocol, Shift+Enter can’t be distinguished from Enter, so it submits. Use a terminal with the Kitty protocol (Ghostty, Kitty, foot) — or tmux — if you want multi-line input.

Bracketed paste

panto enables bracketed paste, so multi-line pastes arrive as a single literal block rather than being re-interpreted as a stream of keypresses (a stray newline in a paste won’t submit your message early).

What isn’t here yet

The input model reserves space for richer behaviour — key-release events and super/hyper modifiers under the full Kitty protocol — but those aren’t consumed today, so they’re not bindings yet.
+

Reference

Key bindings

TUI input

The session UI reads raw keystrokes and negotiates richer terminal keyboard protocols at startup. These are the bindings the current TUI recognizes.

Editing & cursor

The input box is multi-line and accepts full UTF-8 text. Pasted text arrives as one literal block via bracketed paste.

KeyAction
any printable keyInsert text at the cursor.
/ Move one character.
Alt+B / Alt+F
Ctrl+ / Ctrl+
Move by word. Terminals vary in which encoding they send; panto accepts the common ones.
Ctrl+A / Ctrl+EJump to start / end of the current line.
Backspace / DeleteDelete backward / forward.
Ctrl+U / Ctrl+WDelete to line start / delete previous word.
pasteInsert the pasted bytes literally; pasted newlines do not auto-submit.

Submit & newline

KeyAction
EnterSubmit the current input.
Shift+EnterInsert a newline when the terminal can report it distinctly.

Whether Shift+Enter works depends on keyboard-protocol support. panto negotiates Kitty first, then falls back to xterm modifyOtherKeys.

Selectors & control

KeyAction
Ctrl+TOpen the model selector.
Ctrl+ROpen the reasoning selector.
Ctrl+OCollapse / expand all tool output.
Ctrl+GEdit the current draft in $EDITOR (or [tui] editor).
EscInterrupt the running turn, close an open selector, or clear the input when idle.
Ctrl+C / Ctrl+DExit cleanly.

Selectors are filterable: type to narrow the list, move with / or Ctrl+N/Ctrl+P, and accept with Enter (or Tab for completion-style pickers).

Selectors are session-local

Choosing a model or reasoning level changes the live session only. Nothing is written back to config.toml or models.toml.

Terminal protocol

panto enables bracketed paste and probes for keyboard protocol support at startup.

Shift + Enter

With the legacy terminal protocol, Enter and Shift+Enter are the same byte. panto pushes the Kitty keyboard protocol and queries the terminal; if that is unavailable it falls back to xterm modifyOtherKeys.

TerminalShift + Enter
Kitty, Ghostty, footSupported via Kitty keyboard protocol.
xterm, tmuxSupported via modifyOtherKeys fallback.
Terminal.app and similarIndistinguishable from Enter, so it submits.

Bracketed paste

Bracketed paste means multi-line pastes are inserted literally instead of being reinterpreted as a sequence of keypresses.

diff --git a/lua.html b/lua.html index 7baeba0..fd77655 100644 --- a/lua.html +++ b/lua.html @@ -20,44 +20,52 @@
Source
- +
-

Reference

Lua extensions

require("panto")

Everything beyond the core is a Lua extension — including the standard tools. An extension is a Lua file that calls into the panto.ext table to add tools, slash commands, and UI behaviour. This is how panto stays small without staying useless.

Overview

Inside an extension, local panto = require("panto") gives you the API. The panto.ext table has three entry points:

register_toolmodel-facing
Add a tool the model can call.
register_commandyou-facing
Add a /slash command to the REPL.
on / emitUI events
Hook the UI event bus to wrap or replace how things render.

All extensions share one long-lived Lua interpreter, so module-global state persists across calls. The runtime includes luv (libuv), so tool handlers can perform async I/O.

Loading extensions

Drop a .lua file into an extensions directory and it loads at startup. panto discovers extensions across three layers:

LayerLocation
base$PANTO_HOME/agent
user$XDG_CONFIG_HOME/panto/extensions/ · else ~/.config/panto/extensions/
project./.panto/extensions/

Project shadows user shadows base. Which extensions and tools actually load is gated by the [extensions] and [tools] allow/deny policies.

Name collisions abort startup

If two surviving extensions register a tool (or command) with the same name, panto refuses to start rather than pick one silently. A Lua command may not shadow a builtin like /compact.

register_tool

A tool is something the model can call. Describe it with a JSON-Schema for its arguments and a handler that returns a string result.

namestringrequired
The tool name the model invokes.
descriptionstringrequired
Shown to the model; explain when to use it.
schematablerequired
A JSON-Schema table (type = "object", properties, required) describing the arguments.
handlerfunction(input)
Receives the decoded arguments table; returns a string result. May yield (async).
.panto/extensions/echo.lualua
local panto = require("panto")
-
-panto.ext.register_tool {
+

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", description = "The text to echo back." },
+      message = { type = "string" },
     },
     required = { "message" },
   },
   handler = function(input)
     return "echo: " .. input.message
   end,
-}

register_command

A slash command runs locally when you type /name in the REPL. The handler runs synchronously and acts by side effect (typically writing output); its return value is ignored.

namestringrequired
Command name, without the leading /.
descriptionstringrequired
One-line summary (for listings).
handlerfunction(args)
args is the trimmed text after the command name — the empty string when none was given.
.panto/extensions/greet.lualua
local panto = require("panto")
-
-panto.ext.register_command {
+}

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",
-  description = "Print a greeting. Optional args name who to greet.",
-  handler = function(args)
-    local who = args ~= "" and args or "world"
-    io.write("\n[greet] hello, " .. who .. "!\n")
+  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 a handler to the UI event bus — the same bus the built-in TUI uses. Handlers fire in registration order, and can wrap or replace the component that renders a given moment of the transcript.

The handler receives an event object e that is valid only during the call (copy any field you need into a local — don’t read e later at render time):

e.namestring
The event name.
e.<field>
Read-only payload fields (nil when absent, so you can branch on them).
e:getComponent()
The current native component, as an opaque pass-through handle.
e:setComponent(c)
Replace the component for this boundary — pass the handle back to wrap it, or a Lua component table to replace it.

The events and the payload fields they carry:

EventPayload fields
toolindex, tool_name, id, delta, input, output
thinkingindex, delta, text
assistant_textindex, delta, text
user_messagetext
session_startversion, cwd, model
compactionsummary
custom— produced by emit
.panto/extensions/skill-badge.lualua
local panto = require("panto")
-
--- Change how the "skill" tool renders in the transcript.
-panto.ext.on("tool", function(e)
-  if e.tool_name ~= "skill" then return end   -- ignore every other tool
-  local name = e.tool_name                     -- snapshot now: e is short-lived
-  e:setComponent({
-    render = function(self, width)
-      return { "▸ running skill: " .. name }
-    end,
-  })
-end)

panto.ext.emit(name, data) fires a custom event on the same bus, running both native and Lua handlers. Today data is surfaced as an opaque custom payload.

Lua components

A Lua component is a table you hand to e:setComponent. The one required method is render:

renderfunction(self, width)required
Return an array of strings — one per line. Runs synchronously (it may not yield), each line is truncated to width columns, and any error becomes a safe fallback line so the frame never crashes. An empty array is valid (zero lines).
handleInputfunction(self, data)
Optional. Handle input routed to the component.
firstLineChanged / invalidatefunction
Optional render-cache hooks.

Runtime & luarocks

panto embeds a pinned Lua and luarocks, and ships exactly one battery: luv (libuv’s event loop), the single runtime dependency. It drives panto’s coroutine scheduler and gives extensions one rich async-I/O surface. The bootstrap reconciles the installed rocks tree against panto’s manifest on every startup — installing what’s missing, removing what’s stale.

Anything else is yours to add. Install extra rocks into panto’s tree with the embedded luarocks, via panto lua:

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

One-time setup

On a fresh machine, run panto bootstrap once to build the rocks tree before your extensions need it.
+}

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")'
-- cgit v1.3