Clears every deferred extension todo in one push:
1) Interception expands to three events: tool_call (already shipped),
turn_start (gate the turn before the model call, e.g. rate-limit /
business-hour), and assistant_message (suppress or rewrite the
user-visible text while keeping the model's original output in
the transcript).
2) Tool-call args can now be rewritten mid-flight. An interceptor
returning modified_args replaces the JSON the tool actually
receives, without the model seeing the rewrite. Chains: each
subscriber sees the previous one's output, letting guards
successively redact / patch / augment. Invalid JSON is dropped
safely.
3) /reload-ext hot-reloads every extension without restarting zot.
The manager gracefully shuts down all running subprocesses,
re-reads extension.json from disk, respawns (including --ext
paths remembered from startup), and the host rebuilds the agent's
tool registry in-place so freshly-registered tools are callable
immediately.
Wire-format changes (extproto):
- EventInterceptResponseFromExt gains modified_args and replace_text
fields (both optional, ignored when block=true).
- EventInterceptFromHost gains Step (for turn_start) and Text (for
assistant_message) alongside the existing tool_call payload.
Core agent changes:
- BeforeToolExecute signature now returns (allowed, reason,
modifiedArgs json.RawMessage). Non-nil+valid JSON args replace
tc.Arguments before Tool.Execute runs.
- New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking
cancels the turn with an EvTurnEnd{StopError} carrying the reason.
- New BeforeAssistantMessage hook, invoked after finalMsg is
assembled but before the EvAssistantMessage emit. Supports
suppress (block=true) and text rewrite (replace_text). Transcript
always gets the original; UI gets the rewritten text.
- New SetTools(reg) so /reload-ext can swap the registry on the
live agent under the agent mutex.
Manager changes:
- InterceptToolCall now returns InterceptResult (Block, Reason,
ModifiedArgs, ReplaceText), with a chain that folds rewrites.
- New InterceptTurnStart and InterceptAssistantMessage.
- New Reload(ctx, grace) tears down and respawns everything,
returning ReloadStats{Stopped, Loaded, Ready, Errors}.
- New SetOnReload(fn) callback the host uses to rebuild the agent
tool registry after a reload.
- LoadExplicit remembers --ext paths so Reload respawns them.
- subscribe accepts "tool_call", "turn_start", "assistant_message"
under "intercept".
SDK (pkg/zotext):
- New handler types: ToolCallHandler, TurnStartHandler,
AssistantMessageHandler, and their decision structs
(ToolCallDecision with ModifiedArgs, AssistantMessageDecision
with ReplaceText).
- New registration methods: InterceptToolCallX (rich variant of
the existing InterceptToolCall), InterceptTurnStart,
InterceptAssistantMessage.
- dispatchIntercept routes per-event with panic recovery and
always emits exactly one event_intercept_response.
TUI:
- /reload-ext slash command registered in slashCatalog and
runSlash. Added to slashCancelsTurn so it waits for idle like
/compact does.
- runReloadExt shows a "reloading extensions..." status, runs the
Manager.Reload on a goroutine, and reports the resulting stats.
Tests:
- internal/core/intercept_test.go: verifies args are actually
rewritten on the way to Tool.Execute, malformed JSON is ignored,
and block surfaces the reason as an error ToolResult.
- internal/agent/extensions/intercept_test.go: end-to-end with a
bash extension subprocess that blocks rm -rf, rewrites other bash
args to "echo GUARDED:", passes through read calls, allows
turn_start, and redacts SECRET in assistant messages. Second test
verifies Reload respawns the subprocess, re-registers its command,
and fires the onReload callback.
Docs:
- docs/extensions.md: rewrote the intercept section to cover all
three events, added a table of event_intercept_response fields,
documented the /reload-ext hot-reload command, expanded the SDK
section with examples of every handler, moved the old "future"
items into a shipped Phase 4.
- README.md: extensions summary mentions intercept beyond tool_call,
/reload-ext added to the slash-commands table and to the
turn-cancel list in "Queued messages".
18 KiB
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.
Three 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.
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:
- 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.
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
- Discovery: zot reads every
extension.jsonin 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 sends a
helloframe; zot replies withhello_ackcontaining the protocol version and the active provider/model/cwd. - Registration: the extension sends
register_commandframes. 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: zot dispatches
command_invokedframes when the user runs a registered command; the extension responds withcommand_response. Extensions can also pushnotifyframes at any time. - Shutdown: when zot exits, it sends
shutdownand waits up to 2s for the extension to sendshutdown_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"}]}
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 seesreasonas the tool error) or rewrite args viamodified_args.turn_start: block the turn before the model is called. Useful for rate-limiting and business-hour gates.reasonis shown to the user as a status line. No rewrite supported.assistant_message: suppress the message viablock, or rewrite the user-visible text viareplace_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"— submitspromptas a fresh user message; the agent runs a turn against it."insert"— insertsinsertinto the editor at the cursor without submitting."display"— appendsdisplayto 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 pushednotifyframes 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.
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-..."}
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 commandsexamples/extensions/clock/— slash commands in plain Node, no SDKexamples/extensions/weather/— LLM-callable toolexamples/extensions/guard/— event subscriptions + tool-call interception (refuses dangerous bash patterns)
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_invokednotifyzot extCLI
Phase 2 (shipped):
register_tool+tool_call+tool_resultreadysentinel 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_startandassistant_message(in addition totool_call) - modify tool args mid-flight via
modified_args - rewrite user-visible assistant text via
replace_text /reload-extslash 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)