v0.0.23 74709a0

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.

Two 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) is on the roadmap below.

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.

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.

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 and the active provider/model/cwd.
  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.
  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"]}

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

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.
  • "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.

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

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

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.).

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.

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.

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/.

See:

  • examples/extensions/hello/ — slash commands
  • examples/extensions/clock/ — slash commands in plain Node, no SDK
  • examples/extensions/weather/ — LLM-callable tool

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:

  • event subscriptions (turn_start, turn_end, tool_call_*, session_start, etc.)
  • tool-call interception (block / modify before execution)
  • /reload-ext slash command (hot-reload without restarting zot)
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
9Two phases shipped so far:
10
11- **Phase 1**: slash commands + chat notifications.
12- **Phase 2**: tools the LLM can call.
13
14Phase 3 (lifecycle event subscriptions + tool-call interception for
15guardrail extensions) is on the roadmap below.
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## Layout & discovery
66
67zot scans two directories on startup, in this order:
68
691. **Project-local**: `./.zot/extensions/<name>/extension.json`
702. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
71
72A project-local extension with the same name wins over a global one.
73On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
74on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
75
76Each extension owns its own subdirectory. The `extension.json`
77manifest tells zot how to launch it:
78
79```json
80{
81  "name": "weather",
82  "version": "1.0.0",
83  "exec": "./weather",
84  "args": ["--mode", "daemon"],
85  "language": "go",
86  "description": "current weather for any city",
87  "enabled": true
88}
89```
90
91| field | meaning |
92|---|---|
93| `name` | required. how zot identifies the extension; must match what's sent in the `hello` frame. |
94| `version` | optional. shown in `zot ext list`. |
95| `exec` | required. path to the executable (relative to the manifest). |
96| `args` | optional. extra argv passed to `exec`. |
97| `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
98| `description` | optional. shown in `zot ext list`. |
99| `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
100
101## Lifecycle
102
1031. **Discovery**: zot reads every `extension.json` in the search dirs.
1042. **Spawn**: enabled extensions are launched as subprocesses. stderr
105   redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
106   extension, append-mode).
1073. **Hello handshake**: the extension sends a `hello` frame; zot
108   replies with `hello_ack` containing the protocol version and the
109   active provider/model/cwd.
1104. **Registration**: the extension sends `register_command` frames.
111   First-come-first-served: a name already taken by a built-in or by
112   a previously-loaded extension is silently shadowed (logged in the
113   extension's own log file).
1145. **Runtime**: zot dispatches `command_invoked` frames when the
115   user runs a registered command; the extension responds with
116   `command_response`. Extensions can also push `notify` frames at
117   any time.
1186. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
119   2s for the extension to send `shutdown_ack`. Holdouts are
120   SIGTERM'd, then SIGKILL'd.
121
122A crashing extension does not bring down zot. The slash command it
123owned simply stops working until the extension is fixed and zot is
124restarted.
125
126## Wire format
127
128All frames are one JSON object per line. Top-level `type` is the
129discriminator. Optional `id` correlates request frames with their
130responses.
131
132### Extension → host
133
134#### `hello` (required, first frame)
135
136```json
137{"type":"hello","name":"weather","version":"1.0.0",
138 "capabilities":["commands","tools"]}
139```
140
141#### `register_command`
142
143```json
144{"type":"register_command","name":"weather",
145 "description":"current weather for a city"}
146```
147
148#### `register_tool`
149
150Registers a tool the LLM can call. `schema` is a JSON Schema object
151describing the tool's args (the same shape Anthropic and OpenAI accept).
152
153```json
154{"type":"register_tool","name":"weather",
155 "description":"Get the current weather for a city.",
156 "schema":{
157   "type":"object",
158   "properties":{"city":{"type":"string"}},
159   "required":["city"]
160 }}
161```
162
163Tool names live in the same namespace as built-in tools (`read`,
164`write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
165the built-in.
166
167#### `ready`
168
169Sentinel telling zot "all initial registrations are flushed". Send it
170right after your last `register_*` frame so the host can build the
171agent's tool registry without racing the registration window.
172
173```json
174{"type":"ready"}
175```
176
177#### `tool_result`
178
179Reply to a `tool_call` from the host. `content[]` is a list of
180message blocks; each block is `{"type":"text","text":"..."}` or
181`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
182`is_error: true` to mark the call as failed.
183
184```json
185{"type":"tool_result","id":"...",
186 "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
187```
188
189#### `command_response` (reply to `command_invoked`)
190
191```json
192{"type":"command_response","id":"...","action":"prompt",
193 "prompt":"Show today's weather for Berlin in one line."}
194```
195
196`action` is one of:
197
198- `"prompt"` — submits `prompt` as a fresh user message; the agent
199  runs a turn against it.
200- `"insert"` — inserts `insert` into the editor at the cursor without
201  submitting.
202- `"display"` — appends `display` to the chat as a one-shot styled
203  note. No model call, nothing written to the transcript.
204- `"noop"` — the extension handled it itself (e.g. it pushed
205  `notify` frames or kicked off background work). zot doesn't change
206  the UI in response.
207
208If `error` is non-empty, zot renders it as a red status line
209regardless of `action`.
210
211#### `notify` (one-way, any time)
212
213```json
214{"type":"notify","level":"info",
215 "message":"refreshed cache (12 entries)"}
216```
217
218`level` is one of `info`, `success`, `warn`, `error`. The note shows
219up below the transcript with the extension's name in brackets.
220
221#### `shutdown_ack`
222
223Sent in response to `shutdown`. Extension should exit promptly after.
224
225### Host → extension
226
227#### `hello_ack`
228
229```json
230{"type":"hello_ack","protocol_version":1,
231 "zot_version":"0.0.7","provider":"anthropic",
232 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot"}
233```
234
235Sent immediately after `hello`. The extension can use these fields to
236decide which commands to register (e.g. only register a Python tool
237on macOS, only register a model-specific shortcut for opus, etc.).
238
239#### `command_invoked`
240
241```json
242{"type":"command_invoked","id":"...",
243 "name":"weather","args":"berlin"}
244```
245
246`args` is everything the user typed after the command name, trimmed.
247
248#### `tool_call`
249
250Sent when the LLM invokes a tool the extension registered. `args` is
251the parsed JSON object the model produced; the extension is
252responsible for validating/coercing it.
253
254```json
255{"type":"tool_call","id":"...","name":"weather",
256 "args":{"city":"Berlin"}}
257```
258
259Reply with `tool_result` within the host's tool timeout (default 60s).
260Missing the timeout surfaces an error to the model and the call is
261marked as failed.
262
263#### `shutdown`
264
265Sent during graceful zot exit (or `/reload-ext` once that lands).
266Reply with `shutdown_ack` and then exit.
267
268## Managing extensions from the CLI
269
270```
271zot ext list                    list installed extensions and their state
272zot ext install <path|git-url>  copy / clone into $ZOT_HOME/extensions/
273zot ext remove <name>           delete an extension directory
274zot ext enable <name>           re-enable a disabled extension
275zot ext disable <name>          disable without removing
276zot ext logs <name> [-f]        cat / tail the extension's stderr
277```
278
279`zot ext install <path>` does a recursive copy; `<git-url>` does a
280shallow clone. Both validate that the destination contains an
281`extension.json` and roll back if not.
282
283## SDKs
284
285Writing the wire protocol by hand is fine for one-off scripts, but
286for anything bigger the SDKs handle the boilerplate.
287
288### Go — `pkg/zotext`
289
290```go
291package main
292
293import (
294    "encoding/json"
295    "github.com/patriceckhart/zot/pkg/zotext"
296)
297
298func main() {
299    ext := zotext.New("hello", "1.0.0")
300
301    // Slash command
302    ext.Command("hello", "say hi", func(args string) zotext.Response {
303        return zotext.Prompt("Greet me in one short sentence.")
304    })
305
306    // LLM-callable tool
307    ext.Tool("weather", "Current weather for a city.",
308        json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
309        func(args json.RawMessage) zotext.ToolResult {
310            var in struct{ City string `json:"city"` }
311            json.Unmarshal(args, &in)
312            return zotext.TextResult(in.City + ": sunny")
313        })
314
315    ext.Run()
316}
317```
318
319Build with `go build -o hello .`, drop the binary + an `extension.json`
320into `$ZOT_HOME/extensions/hello/`.
321
322See:
323- `examples/extensions/hello/` — slash commands
324- `examples/extensions/clock/` — slash commands in plain Node, no SDK
325- `examples/extensions/weather/` — LLM-callable tool
326
327### TypeScript / Python
328
329These SDKs aren't in the main repo yet; the wire format is small
330enough that a `~30 line` raw script gets you started in either
331language. See the [Quick start](#quick-start) Python example for the
332shape. SDK packages will land in follow-up commits.
333
334## Security
335
336Extensions run with **the user's full filesystem and network
337permissions**. Treat installing an extension the same as installing
338any other binary on your machine.
339
340`zot ext install <git-url>` clones from any URL you give it. There's
341no sandbox in v1; if you need isolation, install only extensions you
342trust or run zot under your platform's sandboxing tool (`bwrap` /
343`sandbox-exec` / AppContainer).
344
345## Roadmap
346
347Phase 1 (shipped):
348- [x] subprocess lifecycle + hello handshake
349- [x] `register_command` + `command_invoked`
350- [x] `notify`
351- [x] `zot ext` CLI
352
353Phase 2 (shipped):
354- [x] `register_tool` + `tool_call` + `tool_result`
355- [x] `ready` sentinel for safe agent-registry build timing
356- [x] tool result attribution surfaces extension name in details
357
358Phase 3:
359- [ ] event subscriptions (`turn_start`, `turn_end`, `tool_call_*`,
360      `session_start`, etc.)
361- [ ] tool-call interception (block / modify before execution)
362- [ ] `/reload-ext` slash command (hot-reload without restarting zot)

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