v0.3.45 b464209

extensions.md RawHistoryFolder

zot extensions

zot can be extended with custom slash commands by running an external program as a subprocess and exchanging newline-delimited JSON over its stdin/stdout. Extensions can be written in any language that can read and write JSON lines from stdio — Go, TypeScript, Python, Rust, shell with jq, anything.

Four phases shipped so far:

  • Phase 1: slash commands + chat notifications.
  • Phase 2: tools the LLM can call.
  • Phase 3: lifecycle event subscriptions + tool-call interception for guardrail extensions.
  • Phase 4: interactive extension-owned panels rendered inside zot.
  • Theme-only extensions: ship theme.json without launching a subprocess. See themes.md.

Quick start

The simplest extension is a script that prints a hello frame, reads commands, and prints responses. Here's the whole thing in Python, no SDK required:

#!/usr/bin/env python3
# $ZOT_HOME/extensions/hello-py/hello.py
import json, sys

def emit(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()

emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})

for line in sys.stdin:
    msg = json.loads(line)
    if msg["type"] == "hello_ack":
        emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
        emit({"type":"ready"})
    elif msg["type"] == "command_invoked":
        emit({"type":"command_response","id":msg["id"],"action":"prompt",
              "prompt": "Greet me very briefly in one sentence."})
    elif msg["type"] == "shutdown":
        emit({"type":"shutdown_ack"})
        break

Drop it in a directory with this extension.json:

{
  "name": "hello-py",
  "version": "1.0.0",
  "exec": "./hello.py",
  "language": "python",
  "enabled": true
}

exec is required for protocol extensions. If an extension only ships theme.json or themes/theme.json, no exec is required and zot does not spawn a subprocess.

chmod +x hello.py, install:

zot ext install ./hello-py

Restart zot, type /hellopy, the agent greets you. Done.

Built-in extensions

zot ships with no extensions installed by default. A fresh zot install (or go install) gives you a clean agent. Extensions are entirely opt-in: you install (or --ext for one run) only the ones you want.

The examples/extensions/ directory in the repo is reference code, not a default install set. To use any of those:

# go-based examples need a build first
cd path/to/zot/examples/extensions/hello && go build -o hello .

# install (copies to $ZOT_HOME/extensions/hello/)
zot ext install path/to/zot/examples/extensions/hello

# or load straight from the repo for one zot session
zot --ext path/to/zot/examples/extensions/hello

Nothing is auto-installed and nothing reaches out to the network without your explicit action.

Layout & discovery

zot scans two directories on startup, in this order:

  1. Project-local: ./.zot/extensions/<name>/extension.json
  2. Global: $ZOT_HOME/extensions/<name>/extension.json

A project-local extension with the same name wins over a global one. When XDG_STATE_HOME is set on any platform, $ZOT_HOME defaults to $XDG_STATE_HOME/zot. Otherwise it defaults to ~/Library/Application Support/zot/ on macOS, ~/.local/state/zot on Linux, or %LOCALAPPDATA%\zot on Windows.

Because each extension owns its own directory, the recommended place for extension state is inside that directory itself (for example todos.json, settings.json, or an auth/cache file used only by that extension). The host also passes this path back in hello_ack as extension_dir / data_dir so runtime code does not need to guess it.

Each extension owns its own subdirectory. The extension.json manifest tells zot how to launch it:

{
  "name": "weather",
  "version": "1.0.0",
  "exec": "./weather",
  "args": ["--mode", "daemon"],
  "language": "go",
  "description": "current weather for any city",
  "enabled": true
}
field meaning
name required. how zot identifies the extension; must match what's sent in the hello frame.
version optional. shown in zot ext list.
exec required. path to the executable (relative to the manifest).
args optional. extra argv passed to exec.
language optional. informational only (go, python, typescript, ...).
description optional. shown in zot ext list.
enabled optional, defaults to true. set to false to disable without removing.

Lifecycle

  1. Discovery: zot reads every extension.json in the search dirs.
  2. Spawn: enabled extensions are launched as subprocesses. stderr redirects to $ZOT_HOME/logs/ext-<name>.log (one file per extension, append-mode).
  3. Hello handshake: the extension's first stdout frame must be hello; zot replies with hello_ack containing the protocol version, the active provider/model/cwd, and the extension's own data directory so it can persist files beside its manifest.
  4. Registration: after receiving hello_ack, the extension sends register_command, register_tool, and subscription frames, then sends ready. First-come-first-served: a name already taken by a built-in or by a previously-loaded extension is silently shadowed (logged in the extension's own log file).
  5. Runtime: after ready, zot dispatches command_invoked frames when the user runs a registered command; the extension responds with command_response. Extensions can also push notify frames during runtime. Panel-capable extensions may open an interactive panel, receive key events, and push redraws while the panel is focused.
  6. Shutdown: when zot exits, it sends shutdown and waits up to 2s for the extension to send shutdown_ack. Holdouts are SIGTERM'd, then SIGKILL'd.

A crashing extension does not bring down zot. The slash command it owned simply stops working until the extension is fixed and zot is restarted.

Wire format

All frames are one JSON object per line. Top-level type is the discriminator. Optional id correlates request frames with their responses. The canonical startup order is hello, hello_ack, registration frames, then ready. Do not send notify, logs, or any other stdout frame before hello.

Extension → host

hello (required, first frame)

{"type":"hello","name":"weather","version":"1.0.0",
 "capabilities":["commands","tools","panels"]}

register_command

{"type":"register_command","name":"weather",
 "description":"current weather for a city"}

Command names are matched case-insensitively. zot sends command_invoked.name using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active.

register_tool

Registers a tool the LLM can call. schema is a JSON Schema object describing the tool's args (the same shape Anthropic and OpenAI accept).

{"type":"register_tool","name":"weather",
 "description":"Get the current weather for a city.",
 "schema":{
   "type":"object",
   "properties":{"city":{"type":"string"}},
   "required":["city"]
 }}

Tool names live in the same namespace as built-in tools (read, write, edit, bash, skill). Conflicts are silently shadowed by the built-in.

Set "deferred": true to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in activate_tools:

{"type":"tool_result","id":"...",
 "content":[{"type":"text","text":"Enabled weather lookup"}],
 "activate_tools":["weather"]}

On Kimi K3's OpenAI-compatible routes, zot places newly activated schemas at the tool-result position using Kimi's native deferred-tool format. Other models receive the complete active tool list on the next request. Unknown names are ignored. The Go extension SDK exposes DeferredTool and ToolResult.ActivateTools for the same protocol.

ready

Sentinel telling zot "all initial registrations are flushed". Send it right after your last register_* frame so the host can build the agent's tool registry without racing the registration window.

{"type":"ready"}

tool_result

Reply to a tool_call from the host. content[] is a list of message blocks; each block is {"type":"text","text":"..."} or {"type":"image","mime_type":"image/png","data":"<base64>"}. Set is_error: true to mark the call as failed. activate_tools can name registered deferred tools that become available after this result.

{"type":"tool_result","id":"...",
 "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}

subscribe

Declares which lifecycle events the extension wants to observe and which it wants to intercept. Send once after hello, before ready.

{"type":"subscribe",
 "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"],
 "intercept":["tool_call","turn_start","assistant_message"]}

Recognised event names: session_start, turn_start, turn_end, tool_call, tool_confirmation_requested, assistant_message.

tool_confirmation_requested fires only when zot is about to wait for interactive approval. Calls running in yolo mode, calls covered by a remembered approval, and calls blocked before confirmation do not emit it. The event includes tool_id, tool_name, and the short tool_preview shown in the confirmation dialog.

Interceptable events:

  • tool_call: block the call (model sees reason as the tool error) or rewrite args via modified_args.
  • turn_start: block the turn before the model is called. Useful for rate-limiting and business-hour gates. reason is shown to the user as a status line. No rewrite supported.
  • assistant_message: suppress the message via block, or rewrite the user-visible text via replace_text. The model's original text stays in the transcript so the model sees what it actually said on subsequent turns.

event_intercept_response

Reply to an event_intercept from the host. All fields default to "allow, pass through unmodified".

field meaning
block true refuses the action. For tool_call, reason is shown to the model; for turn_start / assistant_message, reason is shown to the user.
reason refusal text (on block) or pass-through note.
modified_args for tool_call: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when block is true.
replace_text for assistant_message: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when block is true.

Missing the response within 5s is treated as "allow" (i.e. an unresponsive extension never stalls the agent). When multiple extensions subscribe to the same event, they're consulted serially; the first block wins and rewrites (args / text) chain: each subsequent interceptor sees the previous one's output.

{"type":"event_intercept_response","id":"...",
 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}

{"type":"event_intercept_response","id":"...",
 "modified_args":{"command":"echo GUARDED: ls"}}

{"type":"event_intercept_response","id":"...",
 "replace_text":"[redacted]"}

command_response (reply to command_invoked)

{"type":"command_response","id":"...","action":"prompt",
 "prompt":"Show today's weather for Berlin in one line."}

action is one of:

  • "prompt" — submits prompt as a fresh user message; the agent runs a turn against it.
  • "insert" — inserts insert into the editor at the cursor without submitting.
  • "display" — appends display to the chat as a one-shot styled note. No model call, nothing written to the transcript.
  • "open_panel" — opens an extension-owned interactive panel inside zot. The panel content lives in open_panel.
  • "noop" — the extension handled it itself (e.g. it pushed notify frames or kicked off background work). zot doesn't change the UI in response.

Example:

{"type":"command_response","id":"...","action":"open_panel",
 "open_panel":{
   "id":"todos-main",
   "title":"Todos",
   "lines":["□ ship panel api","✓ persist state"],
   "footer":"↑/↓ navigate - a add - x complete - esc close"
 }}

If error is non-empty, zot renders it as a red status line regardless of action.

submit (one-way, any time)

Submits text as a user prompt in the interactive host. If the agent is idle, zot starts a turn immediately. If a turn is already running, zot queues the prompt behind it using the same queue path as typed input. Empty or whitespace-only text is ignored.

{"type":"submit","text":"Summarize the selected workspace."}

In print / JSON / RPC modes this frame is ignored because there is no interactive editor or prompt queue.

panel_render (one-way, while a panel is open)

Pushes a fresh frame for an already-open panel.

{"type":"panel_render","panel_id":"todos-main",
 "title":"Todos",
 "lines":["□ ship panel api","✓ persist state"],
 "footer":"↑/↓ navigate - a add - x complete - esc close"}

panel_close

Closes a previously-open panel.

{"type":"panel_close","panel_id":"todos-main"}

notify (one-way, any time)

{"type":"notify","level":"info",
 "message":"refreshed cache (12 entries)"}

level is one of info, success, warn, error. The note shows up below the transcript with the extension's name in brackets. Notes are one-shot: they clear automatically when the user sends their next prompt (and on esc / /clear).

clear_notes (one-way, any time)

Removes every note this extension previously pushed via notify / display. Use it for transient status lines (e.g. an approval prompt) so they do not stack up; notes from other extensions are untouched.

{"type":"clear_notes"}

In --mode rpc, this surfaces to the host as an ext_clear_notes event (alongside ext_notify / ext_display).

shutdown_ack

Sent in response to shutdown. Extension should exit promptly after.

Host → extension

hello_ack

{"type":"hello_ack","protocol_version":1,
 "zot_version":"0.0.7","provider":"anthropic",
 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
 "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}

Sent immediately after hello. Wait for this frame before sending registrations if they depend on host metadata. The extension can use these fields to decide which commands to register (e.g. only register a Python tool on macOS, only register a model-specific shortcut for opus, etc.). cwd is the user's project directory; the extension process itself runs from the extension directory, so do not use os.Getwd() or process.cwd() when you need the project path. extension_dir / data_dir are where the extension should persist its own state (for example todos.json, cached metadata, or auth tokens scoped to that extension).

command_invoked

{"type":"command_invoked","id":"...",
 "name":"weather","args":"berlin"}

args is everything the user typed after the command name, trimmed.

tool_call

Sent when the LLM invokes a tool the extension registered. args is the parsed JSON object the model produced; the extension is responsible for validating/coercing it.

{"type":"tool_call","id":"...","name":"weather",
 "args":{"city":"Berlin"}}

Reply with tool_result within the host's tool timeout (default 60s). Missing the timeout surfaces an error to the model and the call is marked as failed.

event

Lifecycle notification for events the extension subscribed to via subscribe. One-way — no response expected.

{"type":"event","event":"turn_start","step":1}
{"type":"event","event":"tool_call",
 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
{"type":"event","event":"tool_confirmation_requested",
 "tool_id":"...","tool_name":"read","tool_preview":"foo.go"}
{"type":"event","event":"turn_end","stop":"end_turn"}

event_intercept

Sent when zot wants to give the extension a chance to block, modify, or annotate a lifecycle event before it happens. Reply with event_intercept_response within 5s; missing the deadline is treated as "allow".

Payload fields depend on the event:

// tool_call: includes the tool id, name, and parsed args
{"type":"event_intercept","id":"...","event":"tool_call",
 "tool_id":"...","tool_name":"bash",
 "tool_args":{"command":"rm -rf /tmp/foo"}}

// turn_start: includes the step number
{"type":"event_intercept","id":"...","event":"turn_start",
 "step":3}

// assistant_message: includes the assembled text
{"type":"event_intercept","id":"...","event":"assistant_message",
 "text":"here is your api key: sk-ant-..."}

panel_key

Sent while an extension-owned panel is focused. key is a normalized name (up, down, left, right, enter, esc, tab, pageup, pagedown, home, end, backspace, delete, rune). For key:"rune", text carries the typed character.

{"type":"panel_key","panel_id":"todos-main","key":"down"}
{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}

panel_close

Sent when the user closes the focused panel from zot (for example with Esc or Ctrl+C). The extension should treat this as the panel lifetime ending and stop sending panel_render updates for that panel_id.

{"type":"panel_close","panel_id":"todos-main"}

shutdown

Sent during graceful zot exit (or /reload-ext once that lands). Reply with shutdown_ack and then exit.

Managing extensions from the CLI

zot ext list                    list installed extensions and their state
zot ext doctor                  diagnose load, registration, and conflict issues
zot ext install <path|git-url>  copy / clone into $ZOT_HOME/extensions/
zot ext remove <name>           delete an extension directory
zot ext enable <name>           re-enable a disabled extension
zot ext disable <name>          disable without removing
zot ext logs <name> [-f]        cat / tail the extension's stderr

zot ext doctor runs the same discovery path as zot startup, but reports what happened instead of changing the fail-soft runtime behavior. It shows manifest errors, disabled or shadowed extensions, subprocess load errors, ready/auto-ready status, registered commands/tools, registration conflicts, warnings, and each extension's stderr log path.

zot ext install <path> does a recursive copy; <git-url> does a shallow clone. Both validate that the destination contains an extension.json and roll back if not.

Loading an extension for one run

For iteration on a working copy, skip the install + reload cycle and load straight from disk for one zot session:

zot --ext ./my-extension        # short form: -e ./my-extension
zot --ext ./a -e ./b            # repeatable

--ext paths take precedence over installed extensions of the same name, so you can shadow an installed copy with a work-in-progress version without uninstalling first. Nothing is copied or persisted; the extension dies with zot like any other subprocess.

SDKs

Writing the wire protocol by hand is fine for one-off scripts, but for anything bigger the SDKs handle the boilerplate.

Go — packages/agent/ext

package main

import (
    "encoding/json"
    "github.com/patriceckhart/zot/packages/agent/ext"
)

func main() {
    e := ext.New("hello", "1.0.0")

    // Slash command
    e.Command("hello", "say hi", func(args string) ext.Response {
        return ext.Prompt("Greet me in one short sentence.")
    })

    // LLM-callable tool
    e.Tool("weather", "Current weather for a city.",
        json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
        func(args json.RawMessage) ext.ToolResult {
            var in struct{ City string `json:"city"` }
            json.Unmarshal(args, &in)
            return ext.TextResult(in.City + ": sunny")
        })

    // Optional: register project-specific commands after hello_ack.
    e.OnHello(func(host ext.HostInfo) {
        if host.CWD != "" {
            e.Command("cwd", "show the current project directory", func(args string) ext.Response {
                return ext.Display(host.CWD)
            })
        }
    })

    e.Run()
}

Build with go build -o hello ., drop the binary + an extension.json into $ZOT_HOME/extensions/hello/.

OnHello is optional. Use it when configuration or registrations need host metadata such as HostInfo.CWD, Provider, Model, ZotVersion, ExtensionDir, or DataDir. The SDK sends hello, waits for hello_ack, runs OnHello, announces registrations, then sends ready.

The SDK has four interceptor hooks, all optional:

// e is the *ext.Extension returned by ext.New(...).

// Refuse calls or rewrite args before they run.
e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
    if tool == "bash" { /* inspect args, return false, reason */ }
    return true, ""
})

// Richer variant: returns ToolCallDecision so you can also rewrite
// args via ModifiedArgs.
e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
    return ext.ToolCallDecision{
        ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
    }
})

// Block the next turn before the model is called.
e.InterceptTurnStart(func(step int) ext.TurnStartDecision {
    if time.Now().Hour() < 9 { return ext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
    return ext.TurnStartDecision{}
})

// Scrub or rewrite the assistant's final text before the user sees it.
e.InterceptAssistantMessage(func(text string) ext.AssistantMessageDecision {
    return ext.AssistantMessageDecision{
        ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
    }
})

See:

  • examples/extensions/hello/ — slash commands
  • examples/extensions/clock/ — slash commands in plain Node, no SDK
  • examples/extensions/weather/ — LLM-callable tool
  • examples/extensions/guard/ — event subscriptions + tool-call interception (refuses dangerous bash patterns)
  • examples/extensions/todo/ — interactive persistent panel + tool
  • examples/extensions/scratchpad/ — source-run TypeScript commands + tool

Hot reload

Type /reload-ext in the TUI to tear down every running extension subprocess, re-read the manifests from disk, and respawn the set. The agent's tool registry is rebuilt automatically, so freshly- registered extension tools become callable without restarting zot. Useful while developing an extension: edit, save, /reload-ext, done. Explicit --ext paths are remembered and reloaded alongside discovered extensions. The temporary reload status reports each load error in red, including its message, and dismisses itself after five seconds. Extension failures from startup and /reload-ext remain in the scrolling chat until a successful reload or /clear, without being sent to the model or saved in the session transcript. Subprocess startup and handshake errors include the exact stderr log path for the failed extension. If a subprocess exits before sending hello, the error also reports its exit status or terminating signal.

TypeScript / Python

These SDKs aren't in the main repo yet; the wire format is small enough that a ~30 line raw script gets you started in either language. See the Quick start Python example for the shape. SDK packages will land in follow-up commits.

Security

Extensions run with the user's full filesystem and network permissions. Treat installing an extension the same as installing any other binary on your machine.

zot ext install <git-url> clones from any URL you give it. There's no sandbox in v1; if you need isolation, install only extensions you trust or run zot under your platform's sandboxing tool (bwrap / sandbox-exec / AppContainer).

Roadmap

Phase 1 (shipped):

  • subprocess lifecycle + hello handshake
  • register_command + command_invoked
  • notify + clear_notes
  • zot ext CLI

Phase 2 (shipped):

  • register_tool + tool_call + tool_result
  • ready sentinel for safe agent-registry build timing
  • tool result attribution surfaces extension name in details

Phase 3 (shipped):

  • event subscriptions (session_start, turn_start, turn_end, tool_call, assistant_message)
  • tool-call interception (block before execution)

Phase 4 (shipped):

  • interception for turn_start and assistant_message (in addition to tool_call)
  • modify tool args mid-flight via modified_args
  • rewrite user-visible assistant text via replace_text
  • /reload-ext slash command (hot-reload without restarting zot)

Future (no firm timeline):

  • TypeScript and Python SDK packages (currently the wire format is stable enough to hand-roll, see the Python quick-start)
  • HTTP / WebSocket transport variants (today: subprocess stdio)
  • per-extension permission scopes (today: full user privileges)
source
1# zot extensions
2
3zot can be extended with custom slash commands by running an external
4program as a subprocess and exchanging newline-delimited JSON over
5its stdin/stdout. Extensions can be written in **any language** that
6can read and write JSON lines from stdio — Go, TypeScript, Python,
7Rust, shell with `jq`, anything.
8
9Four phases shipped so far:
10
11- **Phase 1**: slash commands + chat notifications.
12- **Phase 2**: tools the LLM can call.
13- **Phase 3**: lifecycle event subscriptions + tool-call interception
14  for guardrail extensions.
15- **Phase 4**: interactive extension-owned panels rendered inside zot.
16- **Theme-only extensions**: ship `theme.json` without launching a
17  subprocess. See [themes.md](themes.md).
18
19## Quick start
20
21The simplest extension is a script that prints a hello frame, reads
22commands, and prints responses. Here's the whole thing in **Python**,
23no SDK required:
24
25```python
26#!/usr/bin/env python3
27# $ZOT_HOME/extensions/hello-py/hello.py
28import json, sys
29
30def emit(obj):
31    sys.stdout.write(json.dumps(obj) + "\n")
32    sys.stdout.flush()
33
34emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
35
36for line in sys.stdin:
37    msg = json.loads(line)
38    if msg["type"] == "hello_ack":
39        emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
40        emit({"type":"ready"})
41    elif msg["type"] == "command_invoked":
42        emit({"type":"command_response","id":msg["id"],"action":"prompt",
43              "prompt": "Greet me very briefly in one sentence."})
44    elif msg["type"] == "shutdown":
45        emit({"type":"shutdown_ack"})
46        break
47```
48
49Drop it in a directory with this `extension.json`:
50
51```json
52{
53  "name": "hello-py",
54  "version": "1.0.0",
55  "exec": "./hello.py",
56  "language": "python",
57  "enabled": true
58}
59```
60
61`exec` is required for protocol extensions. If an extension only ships
62`theme.json` or `themes/theme.json`, no `exec` is required and zot does
63not spawn a subprocess.
64
65`chmod +x hello.py`, install:
66
67```bash
68zot ext install ./hello-py
69```
70
71Restart `zot`, type `/hellopy`, the agent greets you. Done.
72
73## Built-in extensions
74
75**zot ships with no extensions installed by default.** A fresh `zot install` (or `go install`) gives you a clean agent. Extensions are entirely opt-in: you install (or `--ext` for one run) only the ones you want.
76
77The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those:
78
79```bash
80# go-based examples need a build first
81cd path/to/zot/examples/extensions/hello && go build -o hello .
82
83# install (copies to $ZOT_HOME/extensions/hello/)
84zot ext install path/to/zot/examples/extensions/hello
85
86# or load straight from the repo for one zot session
87zot --ext path/to/zot/examples/extensions/hello
88```
89
90Nothing is auto-installed and nothing reaches out to the network without your explicit action.
91
92## Layout & discovery
93
94zot scans two directories on startup, in this order:
95
961. **Project-local**: `./.zot/extensions/<name>/extension.json`
972. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
98
99A project-local extension with the same name wins over a global one.
100When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
101`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
102on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
103
104Because each extension owns its own directory, the recommended place
105for extension state is inside that directory itself (for example
106`todos.json`, `settings.json`, or an auth/cache file used only by that
107extension). The host also passes this path back in `hello_ack` as
108`extension_dir` / `data_dir` so runtime code does not need to guess it.
109
110Each extension owns its own subdirectory. The `extension.json`
111manifest tells zot how to launch it:
112
113```json
114{
115  "name": "weather",
116  "version": "1.0.0",
117  "exec": "./weather",
118  "args": ["--mode", "daemon"],
119  "language": "go",
120  "description": "current weather for any city",
121  "enabled": true
122}
123```
124
125| field | meaning |
126|---|---|
127| `name` | required. how zot identifies the extension; must match what's sent in the `hello` frame. |
128| `version` | optional. shown in `zot ext list`. |
129| `exec` | required. path to the executable (relative to the manifest). |
130| `args` | optional. extra argv passed to `exec`. |
131| `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
132| `description` | optional. shown in `zot ext list`. |
133| `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
134
135## Lifecycle
136
1371. **Discovery**: zot reads every `extension.json` in the search dirs.
1382. **Spawn**: enabled extensions are launched as subprocesses. stderr
139   redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
140   extension, append-mode).
1413. **Hello handshake**: the extension's first stdout frame must be
142   `hello`; zot replies with `hello_ack` containing the protocol
143   version, the active provider/model/cwd, and the extension's own
144   data directory so it can persist files beside its manifest.
1454. **Registration**: after receiving `hello_ack`, the extension sends
146   `register_command`, `register_tool`, and subscription frames, then
147   sends `ready`. First-come-first-served: a name already taken by a
148   built-in or by a previously-loaded extension is silently shadowed
149   (logged in the extension's own log file).
1505. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
151   when the user runs a registered command; the extension responds
152   with `command_response`. Extensions can also push `notify` frames
153   during runtime. Panel-capable extensions may open an interactive
154   panel, receive key events, and push redraws while the panel is
155   focused.
1566. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
157   2s for the extension to send `shutdown_ack`. Holdouts are
158   SIGTERM'd, then SIGKILL'd.
159
160A crashing extension does not bring down zot. The slash command it
161owned simply stops working until the extension is fixed and zot is
162restarted.
163
164## Wire format
165
166All frames are one JSON object per line. Top-level `type` is the
167discriminator. Optional `id` correlates request frames with their
168responses. The canonical startup order is `hello`, `hello_ack`,
169registration frames, then `ready`. Do not send `notify`, logs, or any
170other stdout frame before `hello`.
171
172### Extension → host
173
174#### `hello` (required, first frame)
175
176```json
177{"type":"hello","name":"weather","version":"1.0.0",
178 "capabilities":["commands","tools","panels"]}
179```
180
181#### `register_command`
182
183```json
184{"type":"register_command","name":"weather",
185 "description":"current weather for a city"}
186```
187
188Command names are matched case-insensitively. zot sends `command_invoked.name` using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active.
189
190#### `register_tool`
191
192Registers a tool the LLM can call. `schema` is a JSON Schema object
193describing the tool's args (the same shape Anthropic and OpenAI accept).
194
195```json
196{"type":"register_tool","name":"weather",
197 "description":"Get the current weather for a city.",
198 "schema":{
199   "type":"object",
200   "properties":{"city":{"type":"string"}},
201   "required":["city"]
202 }}
203```
204
205Tool names live in the same namespace as built-in tools (`read`,
206`write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
207the built-in.
208
209Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
210
211```json
212{"type":"tool_result","id":"...",
213 "content":[{"type":"text","text":"Enabled weather lookup"}],
214 "activate_tools":["weather"]}
215```
216
217On Kimi K3's OpenAI-compatible routes, zot places newly activated schemas at the tool-result position using Kimi's native deferred-tool format. Other models receive the complete active tool list on the next request. Unknown names are ignored. The Go extension SDK exposes `DeferredTool` and `ToolResult.ActivateTools` for the same protocol.
218
219#### `ready`
220
221Sentinel telling zot "all initial registrations are flushed". Send it
222right after your last `register_*` frame so the host can build the
223agent's tool registry without racing the registration window.
224
225```json
226{"type":"ready"}
227```
228
229#### `tool_result`
230
231Reply to a `tool_call` from the host. `content[]` is a list of
232message blocks; each block is `{"type":"text","text":"..."}` or
233`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
234`is_error: true` to mark the call as failed. `activate_tools` can name
235registered deferred tools that become available after this result.
236
237```json
238{"type":"tool_result","id":"...",
239 "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
240```
241
242#### `subscribe`
243
244Declares which lifecycle events the extension wants to observe and
245which it wants to intercept. Send once after `hello`, before `ready`.
246
247```json
248{"type":"subscribe",
249 "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"],
250 "intercept":["tool_call","turn_start","assistant_message"]}
251```
252
253Recognised event names: `session_start`, `turn_start`, `turn_end`,
254`tool_call`, `tool_confirmation_requested`, `assistant_message`.
255
256`tool_confirmation_requested` fires only when zot is about to wait for
257interactive approval. Calls running in yolo mode, calls covered by a
258remembered approval, and calls blocked before confirmation do not emit it.
259The event includes `tool_id`, `tool_name`, and the short `tool_preview`
260shown in the confirmation dialog.
261
262Interceptable events:
263
264- `tool_call`: block the call (model sees `reason` as the tool
265  error) or rewrite args via `modified_args`.
266- `turn_start`: block the turn before the model is called. Useful
267  for rate-limiting and business-hour gates. `reason` is shown to
268  the user as a status line. No rewrite supported.
269- `assistant_message`: suppress the message via `block`, or rewrite
270  the user-visible text via `replace_text`. The model's original
271  text stays in the transcript so the model sees what it actually
272  said on subsequent turns.
273
274#### `event_intercept_response`
275
276Reply to an `event_intercept` from the host. All fields default to
277"allow, pass through unmodified".
278
279| field | meaning |
280|---|---|
281| `block` | `true` refuses the action. For `tool_call`, `reason` is shown to the model; for `turn_start` / `assistant_message`, `reason` is shown to the user. |
282| `reason` | refusal text (on block) or pass-through note. |
283| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
284| `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. |
285
286Missing the response within 5s is treated as "allow" (i.e. an
287unresponsive extension never stalls the agent). When multiple
288extensions subscribe to the same event, they're consulted serially;
289the first `block` wins and rewrites (args / text) chain: each
290subsequent interceptor sees the previous one's output.
291
292```json
293{"type":"event_intercept_response","id":"...",
294 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
295
296{"type":"event_intercept_response","id":"...",
297 "modified_args":{"command":"echo GUARDED: ls"}}
298
299{"type":"event_intercept_response","id":"...",
300 "replace_text":"[redacted]"}
301```
302
303#### `command_response` (reply to `command_invoked`)
304
305```json
306{"type":"command_response","id":"...","action":"prompt",
307 "prompt":"Show today's weather for Berlin in one line."}
308```
309
310`action` is one of:
311
312- `"prompt"` — submits `prompt` as a fresh user message; the agent
313  runs a turn against it.
314- `"insert"` — inserts `insert` into the editor at the cursor without
315  submitting.
316- `"display"` — appends `display` to the chat as a one-shot styled
317  note. No model call, nothing written to the transcript.
318- `"open_panel"` — opens an extension-owned interactive panel inside
319  zot. The panel content lives in `open_panel`.
320- `"noop"` — the extension handled it itself (e.g. it pushed
321  `notify` frames or kicked off background work). zot doesn't change
322  the UI in response.
323
324Example:
325
326```json
327{"type":"command_response","id":"...","action":"open_panel",
328 "open_panel":{
329   "id":"todos-main",
330   "title":"Todos",
331   "lines":["□ ship panel api","✓ persist state"],
332   "footer":"↑/↓ navigate - a add - x complete - esc close"
333 }}
334```
335
336If `error` is non-empty, zot renders it as a red status line
337regardless of `action`.
338
339#### `submit` (one-way, any time)
340
341Submits text as a user prompt in the interactive host. If the agent is
342idle, zot starts a turn immediately. If a turn is already running, zot
343queues the prompt behind it using the same queue path as typed input.
344Empty or whitespace-only text is ignored.
345
346```json
347{"type":"submit","text":"Summarize the selected workspace."}
348```
349
350In print / JSON / RPC modes this frame is ignored because there is no
351interactive editor or prompt queue.
352
353#### `panel_render` (one-way, while a panel is open)
354
355Pushes a fresh frame for an already-open panel.
356
357```json
358{"type":"panel_render","panel_id":"todos-main",
359 "title":"Todos",
360 "lines":["□ ship panel api","✓ persist state"],
361 "footer":"↑/↓ navigate - a add - x complete - esc close"}
362```
363
364#### `panel_close`
365
366Closes a previously-open panel.
367
368```json
369{"type":"panel_close","panel_id":"todos-main"}
370```
371
372#### `notify` (one-way, any time)
373
374```json
375{"type":"notify","level":"info",
376 "message":"refreshed cache (12 entries)"}
377```
378
379`level` is one of `info`, `success`, `warn`, `error`. The note shows
380up below the transcript with the extension's name in brackets. Notes
381are one-shot: they clear automatically when the user sends their next
382prompt (and on `esc` / `/clear`).
383
384#### `clear_notes` (one-way, any time)
385
386Removes every note this extension previously pushed via `notify` /
387`display`. Use it for transient status lines (e.g. an approval prompt)
388so they do not stack up; notes from other extensions are untouched.
389
390```json
391{"type":"clear_notes"}
392```
393
394In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
395event (alongside `ext_notify` / `ext_display`).
396
397#### `shutdown_ack`
398
399Sent in response to `shutdown`. Extension should exit promptly after.
400
401### Host → extension
402
403#### `hello_ack`
404
405```json
406{"type":"hello_ack","protocol_version":1,
407 "zot_version":"0.0.7","provider":"anthropic",
408 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
409 "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
410 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
411```
412
413Sent immediately after `hello`. Wait for this frame before sending
414registrations if they depend on host metadata. The extension can use
415these fields to decide which commands to register (e.g. only register
416a Python tool on macOS, only register a model-specific shortcut for
417opus, etc.). `cwd` is the user's project directory; the extension
418process itself runs from the extension directory, so do not use
419`os.Getwd()` or `process.cwd()` when you need the project path.
420`extension_dir` / `data_dir` are where the extension should persist
421its own state (for example `todos.json`, cached metadata, or auth
422tokens scoped to that extension).
423
424#### `command_invoked`
425
426```json
427{"type":"command_invoked","id":"...",
428 "name":"weather","args":"berlin"}
429```
430
431`args` is everything the user typed after the command name, trimmed.
432
433#### `tool_call`
434
435Sent when the LLM invokes a tool the extension registered. `args` is
436the parsed JSON object the model produced; the extension is
437responsible for validating/coercing it.
438
439```json
440{"type":"tool_call","id":"...","name":"weather",
441 "args":{"city":"Berlin"}}
442```
443
444Reply with `tool_result` within the host's tool timeout (default 60s).
445Missing the timeout surfaces an error to the model and the call is
446marked as failed.
447
448#### `event`
449
450Lifecycle notification for events the extension subscribed to via
451`subscribe`. One-way — no response expected.
452
453```json
454{"type":"event","event":"turn_start","step":1}
455{"type":"event","event":"tool_call",
456 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
457{"type":"event","event":"tool_confirmation_requested",
458 "tool_id":"...","tool_name":"read","tool_preview":"foo.go"}
459{"type":"event","event":"turn_end","stop":"end_turn"}
460```
461
462#### `event_intercept`
463
464Sent when zot wants to give the extension a chance to block, modify,
465or annotate a lifecycle event before it happens. Reply with
466`event_intercept_response` within 5s; missing the deadline is
467treated as "allow".
468
469Payload fields depend on the event:
470
471```json
472// tool_call: includes the tool id, name, and parsed args
473{"type":"event_intercept","id":"...","event":"tool_call",
474 "tool_id":"...","tool_name":"bash",
475 "tool_args":{"command":"rm -rf /tmp/foo"}}
476
477// turn_start: includes the step number
478{"type":"event_intercept","id":"...","event":"turn_start",
479 "step":3}
480
481// assistant_message: includes the assembled text
482{"type":"event_intercept","id":"...","event":"assistant_message",
483 "text":"here is your api key: sk-ant-..."}
484```
485
486#### `panel_key`
487
488Sent while an extension-owned panel is focused. `key` is a normalized
489name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
490`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
491`key:"rune"`, `text` carries the typed character.
492
493```json
494{"type":"panel_key","panel_id":"todos-main","key":"down"}
495{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
496```
497
498#### `panel_close`
499
500Sent when the user closes the focused panel from zot (for example with
501Esc or Ctrl+C). The extension should treat this as the panel lifetime
502ending and stop sending `panel_render` updates for that `panel_id`.
503
504```json
505{"type":"panel_close","panel_id":"todos-main"}
506```
507
508#### `shutdown`
509
510Sent during graceful zot exit (or `/reload-ext` once that lands).
511Reply with `shutdown_ack` and then exit.
512
513## Managing extensions from the CLI
514
515```
516zot ext list                    list installed extensions and their state
517zot ext doctor                  diagnose load, registration, and conflict issues
518zot ext install <path|git-url>  copy / clone into $ZOT_HOME/extensions/
519zot ext remove <name>           delete an extension directory
520zot ext enable <name>           re-enable a disabled extension
521zot ext disable <name>          disable without removing
522zot ext logs <name> [-f]        cat / tail the extension's stderr
523```
524
525`zot ext doctor` runs the same discovery path as zot startup, but reports
526what happened instead of changing the fail-soft runtime behavior. It shows
527manifest errors, disabled or shadowed extensions, subprocess load errors,
528ready/auto-ready status, registered commands/tools, registration conflicts,
529warnings, and each extension's stderr log path.
530
531`zot ext install <path>` does a recursive copy; `<git-url>` does a
532shallow clone. Both validate that the destination contains an
533`extension.json` and roll back if not.
534
535## Loading an extension for one run
536
537For iteration on a working copy, skip the install + reload cycle
538and load straight from disk for one zot session:
539
540```
541zot --ext ./my-extension        # short form: -e ./my-extension
542zot --ext ./a -e ./b            # repeatable
543```
544
545`--ext` paths take precedence over installed extensions of the same
546name, so you can shadow an installed copy with a work-in-progress
547version without uninstalling first. Nothing is copied or persisted;
548the extension dies with zot like any other subprocess.
549
550## SDKs
551
552Writing the wire protocol by hand is fine for one-off scripts, but
553for anything bigger the SDKs handle the boilerplate.
554
555### Go — `packages/agent/ext`
556
557```go
558package main
559
560import (
561    "encoding/json"
562    "github.com/patriceckhart/zot/packages/agent/ext"
563)
564
565func main() {
566    e := ext.New("hello", "1.0.0")
567
568    // Slash command
569    e.Command("hello", "say hi", func(args string) ext.Response {
570        return ext.Prompt("Greet me in one short sentence.")
571    })
572
573    // LLM-callable tool
574    e.Tool("weather", "Current weather for a city.",
575        json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
576        func(args json.RawMessage) ext.ToolResult {
577            var in struct{ City string `json:"city"` }
578            json.Unmarshal(args, &in)
579            return ext.TextResult(in.City + ": sunny")
580        })
581
582    // Optional: register project-specific commands after hello_ack.
583    e.OnHello(func(host ext.HostInfo) {
584        if host.CWD != "" {
585            e.Command("cwd", "show the current project directory", func(args string) ext.Response {
586                return ext.Display(host.CWD)
587            })
588        }
589    })
590
591    e.Run()
592}
593```
594
595Build with `go build -o hello .`, drop the binary + an `extension.json`
596into `$ZOT_HOME/extensions/hello/`.
597
598`OnHello` is optional. Use it when configuration or registrations need
599host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
600`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
601`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
602
603The SDK has four interceptor hooks, all optional:
604
605```go
606// e is the *ext.Extension returned by ext.New(...).
607
608// Refuse calls or rewrite args before they run.
609e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
610    if tool == "bash" { /* inspect args, return false, reason */ }
611    return true, ""
612})
613
614// Richer variant: returns ToolCallDecision so you can also rewrite
615// args via ModifiedArgs.
616e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
617    return ext.ToolCallDecision{
618        ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
619    }
620})
621
622// Block the next turn before the model is called.
623e.InterceptTurnStart(func(step int) ext.TurnStartDecision {
624    if time.Now().Hour() < 9 { return ext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
625    return ext.TurnStartDecision{}
626})
627
628// Scrub or rewrite the assistant's final text before the user sees it.
629e.InterceptAssistantMessage(func(text string) ext.AssistantMessageDecision {
630    return ext.AssistantMessageDecision{
631        ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
632    }
633})
634```
635
636See:
637- `examples/extensions/hello/` — slash commands
638- `examples/extensions/clock/` — slash commands in plain Node, no SDK
639- `examples/extensions/weather/` — LLM-callable tool
640- `examples/extensions/guard/` — event subscriptions + tool-call
641  interception (refuses dangerous bash patterns)
642- `examples/extensions/todo/` — interactive persistent panel + tool
643- `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
644
645### Hot reload
646
647Type `/reload-ext` in the TUI to tear down every running extension
648subprocess, re-read the manifests from disk, and respawn the set.
649The agent's tool registry is rebuilt automatically, so freshly-
650registered extension tools become callable without restarting zot.
651Useful while developing an extension: edit, save, `/reload-ext`,
652done. Explicit `--ext` paths are remembered and reloaded alongside
653discovered extensions. The temporary reload status reports each load
654error in red, including its message, and dismisses itself after five
655seconds. Extension failures from startup and `/reload-ext` remain in the
656scrolling chat until a successful reload or `/clear`, without being sent to
657the model or saved in the session transcript. Subprocess startup and
658handshake errors include the exact stderr log path for the failed extension.
659If a subprocess exits before sending `hello`, the error also reports its exit
660status or terminating signal.
661
662### TypeScript / Python
663
664These SDKs aren't in the main repo yet; the wire format is small
665enough that a `~30 line` raw script gets you started in either
666language. See the [Quick start](#quick-start) Python example for the
667shape. SDK packages will land in follow-up commits.
668
669## Security
670
671Extensions run with **the user's full filesystem and network
672permissions**. Treat installing an extension the same as installing
673any other binary on your machine.
674
675`zot ext install <git-url>` clones from any URL you give it. There's
676no sandbox in v1; if you need isolation, install only extensions you
677trust or run zot under your platform's sandboxing tool (`bwrap` /
678`sandbox-exec` / AppContainer).
679
680## Roadmap
681
682Phase 1 (shipped):
683- [x] subprocess lifecycle + hello handshake
684- [x] `register_command` + `command_invoked`
685- [x] `notify` + `clear_notes`
686- [x] `zot ext` CLI
687
688Phase 2 (shipped):
689- [x] `register_tool` + `tool_call` + `tool_result`
690- [x] `ready` sentinel for safe agent-registry build timing
691- [x] tool result attribution surfaces extension name in details
692
693Phase 3 (shipped):
694- [x] event subscriptions (`session_start`, `turn_start`, `turn_end`,
695      `tool_call`, `assistant_message`)
696- [x] tool-call interception (block before execution)
697
698Phase 4 (shipped):
699- [x] interception for `turn_start` and `assistant_message` (in
700      addition to `tool_call`)
701- [x] modify tool args mid-flight via `modified_args`
702- [x] rewrite user-visible assistant text via `replace_text`
703- [x] `/reload-ext` slash command (hot-reload without restarting zot)
704
705Future (no firm timeline):
706- [ ] TypeScript and Python SDK packages (currently the wire format
707      is stable enough to hand-roll, see the Python quick-start)
708- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
709- [ ] per-extension permission scopes (today: full user privileges)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close