1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
|
# 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 <id> Resume the conversation whose id begins with <id>.
panto sessions List saved sessions for this directory.
panto auth status Show configured auth sessions and login state.
panto auth login <name> Log in to an OAuth auth session (device flow).
panto auth logout <name> 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:
`<short-id> <created> <message-count> messages`. short-id = first 8 hex chars of the session
UUIDv7. created trimmed to `YYYY-MM-DD HH:MM`. "no sessions for <cwd>" 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 <name>)"
- login <name>: OAuth device flow. Prints: "To authorize, open this URL in a browser: <uri>" and
"enter the code: <user_code>". Runs secondary token exchange now if configured. api_key sessions:
"'<name>' is an api_key session; nothing to log in to".
- logout <name>: deletes the stored token set ("logged out of '<name>'" / "no stored token for '<name>'").
## 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.<name>] define providers
- [defaults] model = "<provider>:<alias>" 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.<n>] 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.<name>] (networked provider; transport only)
- style (required) — one of: openai_chat | anthropic_messages | openai_responses
- base_url (required) — string
- auth (required) — names the [auth.<n>] 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.<name>.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.<name>] (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>:<alias>" — 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 = <int > 0> — kept-suffix token budget (omitted ⇒ libpanto default).
- model = "<provider>:<alias>" — 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: [<provider>.<alias>] — <provider> matches a [providers.<name>] in config.toml;
<alias> is the short name referenced as <provider>:<alias> (e.g. anthropic:sonnet).
Keys:
- model = <string> wire model id sent to the API; defaults to <alias> if omitted.
- max_tokens = <int> 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 = <float>
output = <float>
cache_read = <float>
cache_write = <float>
- openai_chat ONLY:
reasoning = <string> default | off | minimal | low | medium | high (default: default)
- anthropic_messages ONLY:
api_version = <string> Anthropic-Version header; default "2023-06-01"
thinking = <string> disabled | enabled | adaptive (default: disabled)
effort = <string> low | medium | high | xhigh | max (default: medium)
— only used when thinking = "adaptive"
thinking_budget_tokens = <int> max reasoning tokens for thinking="enabled"; default 32000;
null ⇒ max_tokens − 1; ignored when adaptive/disabled
thinking_interleaved = <bool> 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 <name>` -> 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+<key> -> decoded (ESC-prefixed alt forms)
Control:
- Ctrl+C -> interrupt / quit
- Ctrl+D -> EOF / exit
- Esc -> escape
- Ctrl+<letter> 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 <id>` → resume session whose id has prefix <id>.
- 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> (<n> 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.<field> -> 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.
|