Two new capabilities, both ride on the existing subprocess
protocol with a couple of new frame types.
Event subscriptions (one-way notifications):
ext -> host: subscribe {events: [...], intercept: [...]}
host -> ext: event {event, ...payload}
Recognised events: session_start, turn_start, turn_end,
tool_call, assistant_message. Subscribers get fire-and-forget
notifications on each. Useful for telemetry, audit logs, custom
state widgets that follow live agent activity.
Tool-call interception (round-trip, can refuse):
host -> ext: event_intercept {id, event:"tool_call", tool_name, tool_args}
ext -> host: event_intercept_response {id, block?, reason?}
When at least one extension subscribed to "tool_call" intercept,
zot asks each one in turn before running every tool call. First
blocker wins; reason becomes the tool-result error text the model
sees. Per-extension 5s timeout treats unresponsive interceptors
as "allow" so a wedged extension never stalls the agent.
Wire format additions (internal/extproto):
ext -> host: SubscribeFromExt, EventInterceptResponseFromExt
host -> ext: EventFromHost, EventInterceptFromHost
Manager (internal/agent/extensions):
- per-extension eventSubs / interceptSubs sets, populated by the
subscribe frame
- EmitEvent fans out to every subscribed extension on its own
goroutine (won't block the agent on slow stdin writes)
- InterceptToolCall walks subscribers serially, returning the
first refusal; 5s timeout per subscriber (allow on timeout)
- readLoop handles event_intercept_response correlations the
same way it handles command/tool responses
Core (internal/core/agent.go):
- Agent.BeforeToolExecute hook called from runOneTool right
before tool.Execute. Returning (allowed=false, reason)
short-circuits with an IsError tool result containing reason.
- Agent.OnEvent observer fires for every emitted AgentEvent;
composed transparently with the per-Prompt sink via wrapSink
so neither the existing TUI nor the rpc loop need changes.
Wiring (internal/agent/cli.go, rpc.go):
- wireAgentExt sets BeforeToolExecute -> InterceptToolCall and
OnEvent -> fanoutAgentEvent for every freshly-built agent
(initial, login rebuild, model swap)
- fanoutAgentEvent translates core AgentEvent kinds into
extproto.EventFromHost. Internal-only events (text_delta,
tool_progress) are dropped to keep the per-extension stream
sane.
- session_start emitted once after extensions come up
SDK (pkg/zotext):
- On(name, EventHandler) registers per-event observers
- InterceptToolCall(InterceptHandler) registers a single
intercept callback
- Run() now also sends a subscribe frame before the ready
sentinel, with the union of subscribed events + intercept
- Frame loop handles "event" and "event_intercept" frames,
runs the handlers (intercepts on a goroutine to avoid
head-of-line blocking)
- Capabilities advertised: commands + tools + events
Example (examples/extensions/guard):
- subscribes to session_start / turn_start / tool_call / turn_end
and writes one-line audit entries
- intercepts every bash call; refuses commands matching
rm -rf, sudo, dd of=/, mkfs, the fork bomb, chmod -R 777
- end-to-end verified live: agent -> bash("rm -rf /tmp/foo")
-> guard refuses -> model sees the refusal text and surfaces
it in its reply ("the guard blocked it, as expected — the
pattern \brm\s+-rf\b matched")
Docs/extensions.md updated with all five new frame types and the
guard example.
13 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.
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"]}
Recognised event names: session_start, turn_start, turn_end,
tool_call, assistant_message. Only tool_call is interceptable
in this version; other names listed under intercept are ignored.
event_intercept_response
Reply to an event_intercept from the host. block: true refuses
the action; reason is shown to the model as the tool error text.
Missing the response within 5s is treated as "allow" (i.e. an
unresponsive extension never stalls the agent).
{"type":"event_intercept_response","id":"...",
"block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
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 a
lifecycle event before it happens. Same payload shape as event.
Reply with event_intercept_response within 5s; missing the deadline
is treated as "allow".
Only event: "tool_call" is sent in this version.
{"type":"event_intercept","id":"...","event":"tool_call",
"tool_id":"...","tool_name":"bash",
"tool_args":{"command":"rm -rf /tmp/foo"}}
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 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)
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)
Future (no firm timeline):
- interception for additional events beyond
tool_call - modify (not just block) tool args mid-flight
/reload-extslash command (hot-reload without restarting zot)