v0.2.4 dfd2501

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, threading

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"]})
emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})

for line in sys.stdin:
    msg = json.loads(line)
    if msg["type"] == "command_invoked":
        emit({"type":"command_response","id":msg["id"],"action":"prompt",
              "prompt": "Greet me very briefly. Add one emoji."})
    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. On macOS $ZOT_HOME defaults to ~/Library/Application Support/zot/; on Linux it's $XDG_STATE_HOME/zot or ~/.local/state/zot.

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 sends a hello frame; 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: the extension sends register_command frames. 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: zot dispatches command_invoked frames when the user runs a registered command; the extension responds with command_response. Extensions can also push notify frames at any time. 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.

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"}

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.

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.

{"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","turn_end","assistant_message"],
 "intercept":["tool_call","turn_start","assistant_message"]}

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

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.

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.

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. 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.). 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":"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 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 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")
        })

    e.Run()
}

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

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.

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