v0.1.98 d55af26

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.

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
}

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 — pkg/zotext

package main

import (
    "encoding/json"
    "github.com/patriceckhart/zot/pkg/zotext"
)

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

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

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

    ext.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:

// Refuse calls or rewrite args before they run.
ext.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.
ext.InterceptToolCallX(func(tool string, args json.RawMessage) zotext.ToolCallDecision {
    return zotext.ToolCallDecision{
        ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
    }
})

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

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