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