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"]}
Include "tool_cancel" in capabilities to opt in to host cancellation
notifications. The Go SDK advertises this automatically. The host only sends
tool_cancel to extensions advertising that exact capability. Missing or
unknown capabilities do not enable cancellation notifications.
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, session_end, user_prompt_submit,
turn_start, turn_end, tool_call, tool_result,
tool_confirmation_requested, permission_decision, assistant_message,
pre_compact, post_compact, subagent_start, subagent_stop.
The new lifecycle events are observational only; adding them to intercept
does not make them blocking hooks. See Lifecycle payloads.
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.
Set "interactive": true on register_tool for a tool that intentionally
waits for user input. Omitted or false retains the normal 60-second reply
timeout. Interactive tools have no host-imposed reply deadline, but context
cancellation, context deadlines, and extension disconnects still end the call.
Print, JSON, RPC, and other hosts without interactive tool support reject
these calls with a tool error instead of opening an invisible panel.
{"type":"register_tool","name":"ask_user",
"description":"Ask the user a question","schema":{"type":"object"},
"interactive":true}
The Go SDK provides a context-aware handler:
e.InteractiveTool("ask_user", "Ask the user a question", schema,
func(ctx context.Context, args json.RawMessage) ext.ToolResult {
// Open your panel and arrange for its callbacks to deliver an answer.
// Use invocation-local state, including a unique panel ID.
defer closeQuestionPanel()
select {
case answer := <-answers:
return ext.TextResult(answer)
case <-ctx.Done():
return ext.TextErrorResult("Question cancelled")
}
})
Here schema, answers, and closeQuestionPanel are extension-owned values.
Panel dismissal must also resolve the handler's wait. Cancellation is
cooperative, the SDK cannot stop a handler that ignores its context. It
cancels active handler contexts on tool_cancel, shutdown, or host EOF.
Existing Tool and DeferredTool handlers remain source-compatible.
The registration field is additive and does not change the protocol version.
Older hosts ignore it and still apply their normal timeout. Legacy extensions
that do not advertise "tool_cancel" receive no cancellation frames. The host
still stops waiting and discards late results, but cannot request cleanup of
abandoned work in those extensions. Both host and extension must support this
feature for cancellable, unlimited waits.
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).
Interactive tools have no reply timeout. Missing a bounded timeout surfaces
an error to the model and marks the call as timed_out. Request writes
retain a separate bounded transport timeout, even for interactive tools.
Cancellation during a blocked write disconnects the extension transport
because a partially written frame cannot safely be withdrawn. Other pending
calls on that transport also fail.
Sent best-effort when a delivered tool invocation is abandoned by cancellation
or its reply timeout, and only if the extension advertised "tool_cancel" in
hello.capabilities. Normal completion does not send a cancellation frame.
id is the opaque correlation ID from tool_call, not an agent transcript
tool ID.
{"type":"tool_cancel","id":"..."}
Stop the associated work, close its panel, and discard its pending state.
Unknown or already-completed IDs should be ignored. Cancellation can arrive
before the handler goroutine starts, so register cancellation state before
dispatching work. Delivery is not guaranteed after a transport disconnect.
Extensions must also clean up on shutdown and EOF. The host safely ignores
late results for abandoned calls, including races with cancellation.
event
Lifecycle notification for events the extension subscribed to via
subscribe. One-way, no response expected. Fields are additive under protocol
version 1. Clients must tolerate unknown fields and event names.
{"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"}
Lifecycle payloads
Notifications include session_id, cwd, and a monotonically increasing
sequence scoped to the extension manager's lifetime. Sequence numbers span
all notifications, not just an extension's subscriptions, so gaps do not by
themselves indicate lost delivery. They reset when zot restarts. Correlate tool
outcomes with (session_id, tool_id), compaction with compaction_id, and swarm
runs with agent_run_id. These are payload identifiers, not request/reply IDs.
| Event |
Additional payload |
Meaning |
session_start |
No additional fields |
An active conversation opens |
session_end |
reason |
The active conversation closes |
user_prompt_submit |
text, optional queued, image_count |
Model input accepted, before appending or queueing |
turn_start |
step |
A model step begins |
turn_end |
stop, optional error |
A model response attempt finishes, before client tool execution |
tool_call |
tool_id, tool_name, tool_args |
The model's original proposed call |
tool_confirmation_requested |
tool_id, tool_name, tool_preview |
Interactive approval is about to be requested |
permission_decision |
tool_id, tool_name, decision, source, stage, optional reason |
An approval or policy decision was resolved |
tool_result |
tool_id, tool_name, tool_args, status, executed, result |
A client tool call reached a terminal outcome |
assistant_message |
text |
Visible assistant text |
pre_compact |
compaction_id, message_count, token_estimate |
A compaction attempt begins |
post_compact |
Same counts and ID, status, optional error |
A compaction attempt finishes, including failure |
subagent_start |
agent_id, agent_run_id, name |
A local swarm runner starts or resumes |
subagent_stop |
Same identity, status, optional error |
That local swarm runner returns |
Prompt submission
text is the input handed to the agent after host preprocessing, not raw editor
keystrokes. This includes extension-generated prompts and queued follow-ups.
Queued input emits once at acceptance with queued:true, not again at
consumption; it can subsequently be withdrawn without reaching the model.
Image bytes are not included; image_count reports attached images. An
image-only prompt may omit text. Slash commands that do not submit model
input, transcript replay, compaction summaries, and internal image mirrors do
not emit this event.
{"type":"event","event":"tool_result","session_id":"session-1","cwd":"/work","sequence":12,"tool_id":"call-1","tool_name":"bash","tool_args":{"command":"go test ./..."},"status":"completed","executed":true,"result":{"content":[{"type":"text","text":"ok"}],"is_error":false}}
status is completed, failed, blocked, cancelled, or timed_out.
Failures include unknown tools, panics, and tool error results. Guards and
built-in jail/manifest policy refusals are blocked. Cancellation and timeout
classification uses typed execution errors or explicit tool outcome metadata,
not error-message matching. A custom tool that reports only is_error without
such metadata is classified as failed.
tool_args contains effective arguments, including accepted interceptor
rewrites and the empty-argument default {}. For a call refused before
execution, it contains the last proposed arguments instead. If the arguments
are malformed JSON, tool_args_raw contains their raw text and tool_args is
omitted, so the outcome remains a valid protocol frame. executed means
Tool.Execute was entered, not that a side effect occurred or was rolled back.
A tool-local policy refusal can therefore be blocked with executed:true.
Pending calls cancelled before execution still receive terminal results.
Provider-executed server tools do not receive client tool_result events.
result.content uses text and base64 image blocks. Observation payloads have a
256 KiB combined text/base64/MIME-data budget and a 128-block limit. Text may be
shortened at a UTF-8 boundary; oversized images and unsupported block types are
omitted. result.truncated:true marks any omission. Tool execution, the model's
result, and the persisted transcript are not truncated by this observation
limit. Private Details and tool activation metadata are not exported.
{"type":"event","event":"permission_decision","session_id":"session-1","tool_id":"call-1","tool_name":"bash","decision":"approved","source":"user","stage":"pre_execution"}
decision is approved, denied, or cancelled. source is user,
remembered, policy, or yolo. Automatic approval is represented by an
approved decision with its source, not a separate decision value. Guard
refusals, preview failures, and no available confirmer use policy.
A pre_execution approval does not bypass tool-local checks. If a tool later
refuses access, an additional denied decision with source:"policy" and
stage:"tool_execution" precedes its blocked result. These events cannot grant
permission or override a refusal. Calls cancelled before checking permission
or rejected as unknown tools can have a result without a permission decision.
Existing mode-specific confirmation behavior is unchanged, including modes
that do not support interactive approval.
Session, compaction, and swarm boundaries
Sessions use the persisted conversation ID when available, otherwise a
runtime-generated ID. Interactive and RPC sessions start when initialized;
single-shot/headless sessions start when the agent first emits an observation.
Switching conversations emits the old session's end before the new start.
Current end reasons are shutdown, session_switch, and cwd_change.
session_end is emitted at most once per activation on these graceful paths.
Switching models or clearing messages does not close the active conversation.
Reloading extensions does not close the session or replay earlier events.
Every compaction attempt emits a matching post_compact, including empty
transcripts, failures, and cancellation. Status is completed, failed,
cancelled, or timed_out. Post counts describe the resulting in-memory
transcript; they do not assert that persistence succeeded. Token estimates
count serialized text bytes divided by four, not exact provider usage or image
tokens. The events do not let extensions modify the summary.
Swarm events are emitted by the parent supervisor, including resumed tasks.
session_id identifies the task's parent conversation, even if the active
conversation later changes. agent_id survives resume; agent_run_id is fresh
for each local runner invocation and pairs start with stop. name currently
uses the agent ID. Stop statuses are completed, failed, cancelled, and
timed_out. Merely loading detached agents from disk does not replay lifecycle
events. Cancellation requests do not emit stop until the runner returns. On
interactive shutdown zot waits up to two seconds for local runners before
closing extensions; unresponsive runners may not produce a final notification.
Ordering, delivery, and privacy
For a normal client tool step, the observable sequence is:
user_prompt_submit
turn_start
assistant_message / tool_call
turn_end
synchronous tool interception
tool_confirmation_requested (when needed)
permission_decision
tool execution
tool_result
next turn_start (if continuing)
This preserves the existing meaning of turn_end: it ends the model response,
not the tool batch. Multiple calls are announced before the batch executes;
each client call then gets its own permission and result events. Retries,
blocked turns, queued prompts, and concurrent swarm tasks can add or interleave
events; do not assume a globally linear workflow from the example alone.
Frames are serialized per extension, including notifications and interception
requests. Notification enqueue does not wait for the subprocess. Each
extension has a bounded outbound queue (256 frames or 16 MiB of queued bytes,
plus one in-flight frame); overflow disconnects it rather than silently dropping
an event and continuing. Synchronous writes are bounded to five seconds.
Graceful shutdown attempts to drain queued frames, including session_end,
before sending shutdown, within the shutdown grace period.
Delivery is best-effort, not a durable or exactly-once audit log. There is no
acknowledgement, replay, or crash recovery for notifications. A crash, forced
termination, slow consumer, or broken pipe can prevent delivery. The protocol's
4 MiB frame limit still applies. Slow event handlers can delay later
interception requests, so extension handlers should hand lengthy work to their
own bounded queue. Existing interception response and failure policies remain
unchanged.
Prompts, effective tool arguments, outputs, paths, and error text may contain
secrets or private data. Only subscribe to content-bearing events you need.
There is no automatic secret redaction; subscriptions are not an extension
security sandbox. This surface is available where zot loads an extension
manager (interactive, print/stream/JSON, RPC, and swarm-child modes). It does
not add extension loading to other hosts or change the RPC client event schema.
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
remove, enable, and disable accept the manifest name shown by
zot ext list or the installed directory name. For example, an extension
named todo installed in zot-todo/ can be removed with either name.
Both global and project-local installations are searched. If a name matches
multiple installations (including a manifest name matching another directory
name), the command reports the matching paths and makes no changes. Use a
unique directory name to disambiguate where possible. logs uses the
manifest name.
remove deletes the installation directory, including state stored inside
it. It does not stop extensions in other running zot sessions. Close those
sessions before uninstalling, otherwise an extension may recreate its state
directory when saving or shutting down. Normal startup ignores directories
without extension.json, including state-only leftovers. It does not delete
that leftover data. Explicit --ext paths still report missing manifests,
and malformed or unreadable manifests still produce errors. After closing
active sessions, inspect and manually remove any leftover state directory
if needed. ext remove requires an installation with a manifest.
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 identify the extension and its directory, configured executable,
and declared language (if present), along with the original error and stderr log
path when the log was opened. Manifest errors also identify the extension
directory. Multiline diagnostics preserve their line breaks in both the temporary
status and scrolling chat.
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
183Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
184notifications. The Go SDK advertises this automatically. The host only sends
185`tool_cancel` to extensions advertising that exact capability. Missing or
186unknown capabilities do not enable cancellation notifications.
187
188#### `register_command`
189
190```json
191{"type":"register_command","name":"weather",
192 "description":"current weather for a city"}
193```
194
195Command 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.
196
197#### `register_tool`
198
199Registers a tool the LLM can call. `schema` is a JSON Schema object
200describing the tool's args (the same shape Anthropic and OpenAI accept).
201
202```json
203{"type":"register_tool","name":"weather",
204 "description":"Get the current weather for a city.",
205 "schema":{
206 "type":"object",
207 "properties":{"city":{"type":"string"}},
208 "required":["city"]
209 }}
210```
211
212Tool names live in the same namespace as built-in tools (`read`,
213`write`, `edit`, `bash`, `skill`). Conflicts are silently shadowed by
214the built-in.
215
216Set `"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`:
217
218```json
219{"type":"tool_result","id":"...",
220 "content":[{"type":"text","text":"Enabled weather lookup"}],
221 "activate_tools":["weather"]}
222```
223
224On 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.
225
226#### `ready`
227
228Sentinel telling zot "all initial registrations are flushed". Send it
229right after your last `register_*` frame so the host can build the
230agent's tool registry without racing the registration window.
231
232```json
233{"type":"ready"}
234```
235
236#### `tool_result`
237
238Reply to a `tool_call` from the host. `content[]` is a list of
239message blocks; each block is `{"type":"text","text":"..."}` or
240`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
241`is_error: true` to mark the call as failed. `activate_tools` can name
242registered deferred tools that become available after this result.
243
244```json
245{"type":"tool_result","id":"...",
246 "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
247```
248
249#### `subscribe`
250
251Declares which lifecycle events the extension wants to observe and
252which it wants to intercept. Send once after `hello`, before `ready`.
253
254```json
255{"type":"subscribe",
256 "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"],
257 "intercept":["tool_call","turn_start","assistant_message"]}
258```
259
260Recognised event names: `session_start`, `session_end`, `user_prompt_submit`,
261`turn_start`, `turn_end`, `tool_call`, `tool_result`,
262`tool_confirmation_requested`, `permission_decision`, `assistant_message`,
263`pre_compact`, `post_compact`, `subagent_start`, `subagent_stop`.
264The new lifecycle events are observational only; adding them to `intercept`
265does not make them blocking hooks. See [Lifecycle payloads](#lifecycle-payloads).
266
267`tool_confirmation_requested` fires only when zot is about to wait for
268interactive approval. Calls running in yolo mode, calls covered by a
269remembered approval, and calls blocked before confirmation do not emit it.
270The event includes `tool_id`, `tool_name`, and the short `tool_preview`
271shown in the confirmation dialog.
272
273Interceptable events:
274
275- `before_agent_start`: inspect and replace the complete system prompt before
276 the first model call. See [System prompt replacement](#system-prompt-replacement).
277- `tool_call`: block the call (model sees `reason` as the tool
278 error) or rewrite args via `modified_args`.
279- `turn_start`: block the turn before the model is called. Useful
280 for rate-limiting and business-hour gates. `reason` is shown to
281 the user as a status line. No rewrite supported.
282- `assistant_message`: suppress the message via `block`, or rewrite
283 the user-visible text via `replace_text`. The model's original
284 text stays in the transcript so the model sees what it actually
285 said on subsequent turns.
286
287#### System prompt replacement
288
289Subscribe with `{"type":"subscribe","intercept":["before_agent_start"]}`.
290After prompt assembly (including context files, skills, and extension tools),
291zot sends a request before the first agent model call:
292
293```json
294{"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"}
295```
296
297Reply with an exact replacement:
298
299```json
300{"type":"event_intercept_response","id":"start-1","system_prompt":"The complete replacement system prompt"}
301```
302
303No trimming, merging, or appending is performed by the host. `""` intentionally
304removes the prompt; omission leaves it unchanged. `block` does not block startup.
305Extensions run serially in successful load order (explicit extensions first,
306then discovery); each receives the previous extension's result.
307
308The result remains stable across normal user messages, retries, and tool-loop
309steps. The event runs again after a model change, conversation clear, session
310change, or explicit prompt rebuild/reset (including interactive extension reload).
311Rebuilds start from the unmodified base prompt, not the last extension result.
312If a prompt rebuild/reset occurs while a hook is waiting, zot discards that
313preparation's result and runs the chain again against the current base prompt,
314even when the reset keeps the same text. The new pass has a new `agent_run_id`.
315Cancellation stops preparation without committing a pending replacement.
316`session_start` remains a separate one-way setup notification.
317
318`session_id` is the persisted conversation ID when available, otherwise a
319runtime-local ID. `agent_run_id` identifies one preparation pass and changes
320on each rebuild. It is shared by all extensions in that pass.
321
322Each extension has a five-second deadline including the request pipe write.
323Invalid/non-string values, malformed responses, crashes, and timeouts preserve
324the current prompt and produce an extension log diagnostic and host warning.
325An omitted value is a normal no-op. The encoded `system_prompt` response value
326is limited to 1 MiB; complete transport frames are limited to 4 MiB. Exceeding
327the frame limit disconnects the extension's read loop. A blocked request write
328closes its input pipe to release the writer. Other subscribed extensions still
329run unless the user cancels startup.
330
331Supported in interactive, print, JSON, and RPC modes. RPC warnings use
332`ext_notify`. Subscribed extensions see the entire prompt, including private
333context-file instructions. This feature is not a security boundary and does
334not change tool permissions or validate the safety of replacement instructions.
335
336#### `event_intercept_response`
337
338Reply to an `event_intercept` from the host. All fields default to
339"allow, pass through unmodified".
340
341| field | meaning |
342|---|---|
343| `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. |
344| `reason` | refusal text (on block) or pass-through note. |
345| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
346| `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. |
347| `system_prompt` | for `before_agent_start`: exact string replacement, including an empty string. Omission keeps the current prompt. |
348
349Missing the response within 5s is treated as "allow, unchanged". For
350`before_agent_start`, this deadline also includes the request write; legacy
351interceptors start the timeout after writing the request. When multiple
352extensions subscribe to the same event, they're consulted serially;
353the first `block` wins for the blocking events and rewrites (args / text)
354chain. `before_agent_start` follows the non-blocking replacement rules above.
355
356```json
357{"type":"event_intercept_response","id":"...",
358 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
359
360{"type":"event_intercept_response","id":"...",
361 "modified_args":{"command":"echo GUARDED: ls"}}
362
363{"type":"event_intercept_response","id":"...",
364 "replace_text":"[redacted]"}
365```
366
367#### `command_response` (reply to `command_invoked`)
368
369```json
370{"type":"command_response","id":"...","action":"prompt",
371 "prompt":"Show today's weather for Berlin in one line."}
372```
373
374`action` is one of:
375
376- `"prompt"` — submits `prompt` as a fresh user message; the agent
377 runs a turn against it.
378- `"insert"` — inserts `insert` into the editor at the cursor without
379 submitting.
380- `"display"` — appends `display` to the chat as a one-shot styled
381 note. No model call, nothing written to the transcript.
382- `"open_panel"` — opens an extension-owned interactive panel inside
383 zot. The panel content lives in `open_panel`.
384- `"noop"` — the extension handled it itself (e.g. it pushed
385 `notify` frames or kicked off background work). zot doesn't change
386 the UI in response.
387
388Example:
389
390```json
391{"type":"command_response","id":"...","action":"open_panel",
392 "open_panel":{
393 "id":"todos-main",
394 "title":"Todos",
395 "lines":["□ ship panel api","✓ persist state"],
396 "footer":"↑/↓ navigate - a add - x complete - esc close"
397 }}
398```
399
400If `error` is non-empty, zot renders it as a red status line
401regardless of `action`.
402
403#### `submit` (one-way, any time)
404
405Submits text as a user prompt in the interactive host. If the agent is
406idle, zot starts a turn immediately. If a turn is already running, zot
407queues the prompt behind it using the same queue path as typed input.
408Empty or whitespace-only text is ignored.
409
410```json
411{"type":"submit","text":"Summarize the selected workspace."}
412```
413
414In print / JSON / RPC modes this frame is ignored because there is no
415interactive editor or prompt queue.
416
417#### `panel_render` (one-way, while a panel is open)
418
419Pushes a fresh frame for an already-open panel.
420
421```json
422{"type":"panel_render","panel_id":"todos-main",
423 "title":"Todos",
424 "lines":["□ ship panel api","✓ persist state"],
425 "footer":"↑/↓ navigate - a add - x complete - esc close"}
426```
427
428#### `panel_close`
429
430Closes a previously-open panel.
431
432```json
433{"type":"panel_close","panel_id":"todos-main"}
434```
435
436#### `notify` (one-way, any time)
437
438```json
439{"type":"notify","level":"info",
440 "message":"refreshed cache (12 entries)"}
441```
442
443`level` is one of `info`, `success`, `warn`, `error`. The note shows
444up below the transcript with the extension's name in brackets. Notes
445are one-shot: they clear automatically when the user sends their next
446prompt (and on `esc` / `/clear`).
447
448#### `clear_notes` (one-way, any time)
449
450Removes every note this extension previously pushed via `notify` /
451`display`. Use it for transient status lines (e.g. an approval prompt)
452so they do not stack up; notes from other extensions are untouched.
453
454```json
455{"type":"clear_notes"}
456```
457
458In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
459event (alongside `ext_notify` / `ext_display`).
460
461#### `shutdown_ack`
462
463Sent in response to `shutdown`. Extension should exit promptly after.
464
465### Host → extension
466
467#### `hello_ack`
468
469```json
470{"type":"hello_ack","protocol_version":1,
471 "zot_version":"0.0.7","provider":"anthropic",
472 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
473 "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
474 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
475```
476
477Sent immediately after `hello`. Wait for this frame before sending
478registrations if they depend on host metadata. The extension can use
479these fields to decide which commands to register (e.g. only register
480a Python tool on macOS, only register a model-specific shortcut for
481opus, etc.). `cwd` is the user's project directory; the extension
482process itself runs from the extension directory, so do not use
483`os.Getwd()` or `process.cwd()` when you need the project path.
484`extension_dir` / `data_dir` are where the extension should persist
485its own state (for example `todos.json`, cached metadata, or auth
486tokens scoped to that extension).
487
488#### `command_invoked`
489
490```json
491{"type":"command_invoked","id":"...",
492 "name":"weather","args":"berlin"}
493```
494
495`args` is everything the user typed after the command name, trimmed.
496
497#### Interactive tools
498
499Set `"interactive": true` on `register_tool` for a tool that intentionally
500waits for user input. Omitted or false retains the normal 60-second reply
501timeout. Interactive tools have no host-imposed reply deadline, but context
502cancellation, context deadlines, and extension disconnects still end the call.
503Print, JSON, RPC, and other hosts without interactive tool support reject
504these calls with a tool error instead of opening an invisible panel.
505
506```json
507{"type":"register_tool","name":"ask_user",
508 "description":"Ask the user a question","schema":{"type":"object"},
509 "interactive":true}
510```
511
512The Go SDK provides a context-aware handler:
513
514```go
515e.InteractiveTool("ask_user", "Ask the user a question", schema,
516 func(ctx context.Context, args json.RawMessage) ext.ToolResult {
517 // Open your panel and arrange for its callbacks to deliver an answer.
518 // Use invocation-local state, including a unique panel ID.
519 defer closeQuestionPanel()
520 select {
521 case answer := <-answers:
522 return ext.TextResult(answer)
523 case <-ctx.Done():
524 return ext.TextErrorResult("Question cancelled")
525 }
526 })
527```
528
529Here `schema`, `answers`, and `closeQuestionPanel` are extension-owned values.
530Panel dismissal must also resolve the handler's wait. Cancellation is
531cooperative, the SDK cannot stop a handler that ignores its context. It
532cancels active handler contexts on `tool_cancel`, shutdown, or host EOF.
533Existing `Tool` and `DeferredTool` handlers remain source-compatible.
534
535The registration field is additive and does not change the protocol version.
536Older hosts ignore it and still apply their normal timeout. Legacy extensions
537that do not advertise `"tool_cancel"` receive no cancellation frames. The host
538still stops waiting and discards late results, but cannot request cleanup of
539abandoned work in those extensions. Both host and extension must support this
540feature for cancellable, unlimited waits.
541
542#### `tool_call`
543
544Sent when the LLM invokes a tool the extension registered. `args` is
545the parsed JSON object the model produced; the extension is
546responsible for validating/coercing it.
547
548```json
549{"type":"tool_call","id":"...","name":"weather",
550 "args":{"city":"Berlin"}}
551```
552
553Reply with `tool_result` within the host's tool timeout (default 60s).
554Interactive tools have no reply timeout. Missing a bounded timeout surfaces
555an error to the model and marks the call as `timed_out`. Request writes
556retain a separate bounded transport timeout, even for interactive tools.
557Cancellation during a blocked write disconnects the extension transport
558because a partially written frame cannot safely be withdrawn. Other pending
559calls on that transport also fail.
560
561#### `tool_cancel`
562
563Sent best-effort when a delivered tool invocation is abandoned by cancellation
564or its reply timeout, and only if the extension advertised `"tool_cancel"` in
565`hello.capabilities`. Normal completion does not send a cancellation frame.
566`id` is the opaque correlation ID from `tool_call`, not an agent transcript
567tool ID.
568
569```json
570{"type":"tool_cancel","id":"..."}
571```
572
573Stop the associated work, close its panel, and discard its pending state.
574Unknown or already-completed IDs should be ignored. Cancellation can arrive
575before the handler goroutine starts, so register cancellation state before
576dispatching work. Delivery is not guaranteed after a transport disconnect.
577Extensions must also clean up on shutdown and EOF. The host safely ignores
578late results for abandoned calls, including races with cancellation.
579
580#### `event`
581
582Lifecycle notification for events the extension subscribed to via
583`subscribe`. One-way, no response expected. Fields are additive under protocol
584version 1. Clients must tolerate unknown fields and event names.
585
586```json
587{"type":"event","event":"turn_start","step":1}
588{"type":"event","event":"tool_call",
589 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
590{"type":"event","event":"tool_confirmation_requested",
591 "tool_id":"...","tool_name":"read","tool_preview":"foo.go"}
592{"type":"event","event":"turn_end","stop":"end_turn"}
593```
594
595### Lifecycle payloads
596
597Notifications include `session_id`, `cwd`, and a monotonically increasing
598`sequence` scoped to the extension manager's lifetime. Sequence numbers span
599all notifications, not just an extension's subscriptions, so gaps do not by
600themselves indicate lost delivery. They reset when zot restarts. Correlate tool
601outcomes with `(session_id, tool_id)`, compaction with `compaction_id`, and swarm
602runs with `agent_run_id`. These are payload identifiers, not request/reply IDs.
603
604| Event | Additional payload | Meaning |
605|---|---|---|
606| `session_start` | No additional fields | An active conversation opens |
607| `session_end` | `reason` | The active conversation closes |
608| `user_prompt_submit` | `text`, optional `queued`, `image_count` | Model input accepted, before appending or queueing |
609| `turn_start` | `step` | A model step begins |
610| `turn_end` | `stop`, optional `error` | A model response attempt finishes, before client tool execution |
611| `tool_call` | `tool_id`, `tool_name`, `tool_args` | The model's original proposed call |
612| `tool_confirmation_requested` | `tool_id`, `tool_name`, `tool_preview` | Interactive approval is about to be requested |
613| `permission_decision` | `tool_id`, `tool_name`, `decision`, `source`, `stage`, optional `reason` | An approval or policy decision was resolved |
614| `tool_result` | `tool_id`, `tool_name`, `tool_args`, `status`, `executed`, `result` | A client tool call reached a terminal outcome |
615| `assistant_message` | `text` | Visible assistant text |
616| `pre_compact` | `compaction_id`, `message_count`, `token_estimate` | A compaction attempt begins |
617| `post_compact` | Same counts and ID, `status`, optional `error` | A compaction attempt finishes, including failure |
618| `subagent_start` | `agent_id`, `agent_run_id`, `name` | A local swarm runner starts or resumes |
619| `subagent_stop` | Same identity, `status`, optional `error` | That local swarm runner returns |
620
621#### Prompt submission
622
623`text` is the input handed to the agent after host preprocessing, not raw editor
624keystrokes. This includes extension-generated prompts and queued follow-ups.
625Queued input emits once at acceptance with `queued:true`, not again at
626consumption; it can subsequently be withdrawn without reaching the model.
627Image bytes are not included; `image_count` reports attached images. An
628image-only prompt may omit `text`. Slash commands that do not submit model
629input, transcript replay, compaction summaries, and internal image mirrors do
630not emit this event.
631
632#### Tool outcomes and permission decisions
633
634```json
635{"type":"event","event":"tool_result","session_id":"session-1","cwd":"/work","sequence":12,"tool_id":"call-1","tool_name":"bash","tool_args":{"command":"go test ./..."},"status":"completed","executed":true,"result":{"content":[{"type":"text","text":"ok"}],"is_error":false}}
636```
637
638`status` is `completed`, `failed`, `blocked`, `cancelled`, or `timed_out`.
639Failures include unknown tools, panics, and tool error results. Guards and
640built-in jail/manifest policy refusals are `blocked`. Cancellation and timeout
641classification uses typed execution errors or explicit tool outcome metadata,
642not error-message matching. A custom tool that reports only `is_error` without
643such metadata is classified as `failed`.
644
645`tool_args` contains effective arguments, including accepted interceptor
646rewrites and the empty-argument default `{}`. For a call refused before
647execution, it contains the last proposed arguments instead. If the arguments
648are malformed JSON, `tool_args_raw` contains their raw text and `tool_args` is
649omitted, so the outcome remains a valid protocol frame. `executed` means
650`Tool.Execute` was entered, not that a side effect occurred or was rolled back.
651A tool-local policy refusal can therefore be `blocked` with `executed:true`.
652Pending calls cancelled before execution still receive terminal results.
653Provider-executed server tools do not receive client `tool_result` events.
654
655`result.content` uses text and base64 image blocks. Observation payloads have a
656256 KiB combined text/base64/MIME-data budget and a 128-block limit. Text may be
657shortened at a UTF-8 boundary; oversized images and unsupported block types are
658omitted. `result.truncated:true` marks any omission. Tool execution, the model's
659result, and the persisted transcript are not truncated by this observation
660limit. Private `Details` and tool activation metadata are not exported.
661
662```json
663{"type":"event","event":"permission_decision","session_id":"session-1","tool_id":"call-1","tool_name":"bash","decision":"approved","source":"user","stage":"pre_execution"}
664```
665
666`decision` is `approved`, `denied`, or `cancelled`. `source` is `user`,
667`remembered`, `policy`, or `yolo`. Automatic approval is represented by an
668approved decision with its source, not a separate decision value. Guard
669refusals, preview failures, and no available confirmer use `policy`.
670
671A `pre_execution` approval does not bypass tool-local checks. If a tool later
672refuses access, an additional denied decision with `source:"policy"` and
673`stage:"tool_execution"` precedes its blocked result. These events cannot grant
674permission or override a refusal. Calls cancelled before checking permission
675or rejected as unknown tools can have a result without a permission decision.
676Existing mode-specific confirmation behavior is unchanged, including modes
677that do not support interactive approval.
678
679#### Session, compaction, and swarm boundaries
680
681Sessions use the persisted conversation ID when available, otherwise a
682runtime-generated ID. Interactive and RPC sessions start when initialized;
683single-shot/headless sessions start when the agent first emits an observation.
684Switching conversations emits the old session's end before the new start.
685Current end reasons are `shutdown`, `session_switch`, and `cwd_change`.
686`session_end` is emitted at most once per activation on these graceful paths.
687Switching models or clearing messages does not close the active conversation.
688Reloading extensions does not close the session or replay earlier events.
689
690Every compaction attempt emits a matching `post_compact`, including empty
691transcripts, failures, and cancellation. Status is `completed`, `failed`,
692`cancelled`, or `timed_out`. Post counts describe the resulting in-memory
693transcript; they do not assert that persistence succeeded. Token estimates
694count serialized text bytes divided by four, not exact provider usage or image
695tokens. The events do not let extensions modify the summary.
696
697Swarm events are emitted by the parent supervisor, including resumed tasks.
698`session_id` identifies the task's parent conversation, even if the active
699conversation later changes. `agent_id` survives resume; `agent_run_id` is fresh
700for each local runner invocation and pairs start with stop. `name` currently
701uses the agent ID. Stop statuses are `completed`, `failed`, `cancelled`, and
702`timed_out`. Merely loading detached agents from disk does not replay lifecycle
703events. Cancellation requests do not emit stop until the runner returns. On
704interactive shutdown zot waits up to two seconds for local runners before
705closing extensions; unresponsive runners may not produce a final notification.
706
707#### Ordering, delivery, and privacy
708
709For a normal client tool step, the observable sequence is:
710
711```text
712user_prompt_submit
713turn_start
714assistant_message / tool_call
715turn_end
716synchronous tool interception
717tool_confirmation_requested (when needed)
718permission_decision
719tool execution
720tool_result
721next turn_start (if continuing)
722```
723
724This preserves the existing meaning of `turn_end`: it ends the model response,
725not the tool batch. Multiple calls are announced before the batch executes;
726each client call then gets its own permission and result events. Retries,
727blocked turns, queued prompts, and concurrent swarm tasks can add or interleave
728events; do not assume a globally linear workflow from the example alone.
729
730Frames are serialized per extension, including notifications and interception
731requests. Notification enqueue does not wait for the subprocess. Each
732extension has a bounded outbound queue (256 frames or 16 MiB of queued bytes,
733plus one in-flight frame); overflow disconnects it rather than silently dropping
734an event and continuing. Synchronous writes are bounded to five seconds.
735Graceful shutdown attempts to drain queued frames, including `session_end`,
736before sending `shutdown`, within the shutdown grace period.
737
738Delivery is best-effort, not a durable or exactly-once audit log. There is no
739acknowledgement, replay, or crash recovery for notifications. A crash, forced
740termination, slow consumer, or broken pipe can prevent delivery. The protocol's
7414 MiB frame limit still applies. Slow event handlers can delay later
742interception requests, so extension handlers should hand lengthy work to their
743own bounded queue. Existing interception response and failure policies remain
744unchanged.
745
746Prompts, effective tool arguments, outputs, paths, and error text may contain
747secrets or private data. Only subscribe to content-bearing events you need.
748There is no automatic secret redaction; subscriptions are not an extension
749security sandbox. This surface is available where zot loads an extension
750manager (interactive, print/stream/JSON, RPC, and swarm-child modes). It does
751not add extension loading to other hosts or change the RPC client event schema.
752
753#### `event_intercept`
754
755Sent when zot wants to give the extension a chance to block, modify,
756or annotate a lifecycle event before it happens. Reply with
757`event_intercept_response` within 5s; missing the deadline is
758treated as "allow".
759
760Payload fields depend on the event:
761
762```json
763// tool_call: includes the tool id, name, and parsed args
764{"type":"event_intercept","id":"...","event":"tool_call",
765 "tool_id":"...","tool_name":"bash",
766 "tool_args":{"command":"rm -rf /tmp/foo"}}
767
768// turn_start: includes the step number
769{"type":"event_intercept","id":"...","event":"turn_start",
770 "step":3}
771
772// assistant_message: includes the assembled text
773{"type":"event_intercept","id":"...","event":"assistant_message",
774 "text":"here is your api key: sk-ant-..."}
775```
776
777#### `panel_key`
778
779Sent while an extension-owned panel is focused. `key` is a normalized
780name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
781`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
782`key:"rune"`, `text` carries the typed character.
783
784```json
785{"type":"panel_key","panel_id":"todos-main","key":"down"}
786{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
787```
788
789#### `panel_close`
790
791Sent when the user closes the focused panel from zot (for example with
792Esc or Ctrl+C). The extension should treat this as the panel lifetime
793ending and stop sending `panel_render` updates for that `panel_id`.
794
795```json
796{"type":"panel_close","panel_id":"todos-main"}
797```
798
799#### `shutdown`
800
801Sent during graceful zot exit (or `/reload-ext` once that lands).
802Reply with `shutdown_ack` and then exit.
803
804## Managing extensions from the CLI
805
806```
807zot ext list list installed extensions and their state
808zot ext doctor diagnose load, registration, and conflict issues
809zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/
810zot ext remove <name> delete an extension directory
811zot ext enable <name> re-enable a disabled extension
812zot ext disable <name> disable without removing
813zot ext logs <name> [-f] cat / tail the extension's stderr
814```
815
816`remove`, `enable`, and `disable` accept the manifest name shown by
817`zot ext list` or the installed directory name. For example, an extension
818named `todo` installed in `zot-todo/` can be removed with either name.
819Both global and project-local installations are searched. If a name matches
820multiple installations (including a manifest name matching another directory
821name), the command reports the matching paths and makes no changes. Use a
822unique directory name to disambiguate where possible. `logs` uses the
823manifest name.
824
825`remove` deletes the installation directory, including state stored inside
826it. It does not stop extensions in other running zot sessions. Close those
827sessions before uninstalling, otherwise an extension may recreate its state
828directory when saving or shutting down. Normal startup ignores directories
829without `extension.json`, including state-only leftovers. It does not delete
830that leftover data. Explicit `--ext` paths still report missing manifests,
831and malformed or unreadable manifests still produce errors. After closing
832active sessions, inspect and manually remove any leftover state directory
833if needed. `ext remove` requires an installation with a manifest.
834
835`zot ext doctor` runs the same discovery path as zot startup, but reports
836what happened instead of changing the fail-soft runtime behavior. It shows
837manifest errors, disabled or shadowed extensions, subprocess load errors,
838ready/auto-ready status, registered commands/tools, registration conflicts,
839warnings, and each extension's stderr log path.
840
841`zot ext install <path>` does a recursive copy; `<git-url>` does a
842shallow clone. Both validate that the destination contains an
843`extension.json` and roll back if not.
844
845## Loading an extension for one run
846
847For iteration on a working copy, skip the install + reload cycle
848and load straight from disk for one zot session:
849
850```
851zot --ext ./my-extension # short form: -e ./my-extension
852zot --ext ./a -e ./b # repeatable
853```
854
855`--ext` paths take precedence over installed extensions of the same
856name, so you can shadow an installed copy with a work-in-progress
857version without uninstalling first. Nothing is copied or persisted;
858the extension dies with zot like any other subprocess.
859
860## SDKs
861
862Writing the wire protocol by hand is fine for one-off scripts, but
863for anything bigger the SDKs handle the boilerplate.
864
865### Go — `packages/agent/ext`
866
867```go
868package main
869
870import (
871 "encoding/json"
872 "github.com/patriceckhart/zot/packages/agent/ext"
873)
874
875func main() {
876 e := ext.New("hello", "1.0.0")
877
878 // Slash command
879 e.Command("hello", "say hi", func(args string) ext.Response {
880 return ext.Prompt("Greet me in one short sentence.")
881 })
882
883 // LLM-callable tool
884 e.Tool("weather", "Current weather for a city.",
885 json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
886 func(args json.RawMessage) ext.ToolResult {
887 var in struct{ City string `json:"city"` }
888 json.Unmarshal(args, &in)
889 return ext.TextResult(in.City + ": sunny")
890 })
891
892 // Optional: register project-specific commands after hello_ack.
893 e.OnHello(func(host ext.HostInfo) {
894 if host.CWD != "" {
895 e.Command("cwd", "show the current project directory", func(args string) ext.Response {
896 return ext.Display(host.CWD)
897 })
898 }
899 })
900
901 e.Run()
902}
903```
904
905Build with `go build -o hello .`, drop the binary + an `extension.json`
906into `$ZOT_HOME/extensions/hello/`.
907
908`OnHello` is optional. Use it when configuration or registrations need
909host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
910`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
911`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
912
913The SDK has five interceptor hooks, all optional:
914
915```go
916// e is the *ext.Extension returned by ext.New(...).
917
918// Replace the complete prompt once per session or explicit rebuild.
919// Return nil to keep it, or a pointer to "" to remove it entirely.
920e.InterceptBeforeAgentStart(func(event ext.BeforeAgentStartEvent) *string {
921 prompt := event.SystemPrompt + "\nUse the project's preferred coding style."
922 return &prompt
923})
924
925// Refuse calls or rewrite args before they run.
926e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
927 if tool == "bash" { /* inspect args, return false, reason */ }
928 return true, ""
929})
930
931// Richer variant: returns ToolCallDecision so you can also rewrite
932// args via ModifiedArgs.
933e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
934 return ext.ToolCallDecision{
935 ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED"}`),
936 }
937})
938
939// Block the next turn before the model is called.
940e.InterceptTurnStart(func(step int) ext.TurnStartDecision {
941 if time.Now().Hour() < 9 { return ext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
942 return ext.TurnStartDecision{}
943})
944
945// Scrub or rewrite the assistant's final text before the user sees it.
946e.InterceptAssistantMessage(func(text string) ext.AssistantMessageDecision {
947 return ext.AssistantMessageDecision{
948 ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
949 }
950})
951```
952
953See:
954- `examples/extensions/hello/` — slash commands
955- `examples/extensions/clock/` — slash commands in plain Node, no SDK
956- `examples/extensions/weather/` — LLM-callable tool
957- `examples/extensions/guard/` — event subscriptions + tool-call
958 interception (refuses dangerous bash patterns)
959- `examples/extensions/todo/` — interactive persistent panel + tool
960- `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
961
962### Hot reload
963
964Type `/reload-ext` in the TUI to tear down every running extension
965subprocess, re-read the manifests from disk, and respawn the set.
966The agent's tool registry is rebuilt automatically, so freshly-
967registered extension tools become callable without restarting zot.
968Useful while developing an extension: edit, save, `/reload-ext`,
969done. Explicit `--ext` paths are remembered and reloaded alongside
970discovered extensions. The temporary reload status reports each load
971error in red, including its message, and dismisses itself after five
972seconds. Extension failures from startup and `/reload-ext` remain in the
973scrolling chat until a successful reload or `/clear`, without being sent to
974the model or saved in the session transcript. Subprocess startup and
975handshake errors identify the extension and its directory, configured executable,
976and declared language (if present), along with the original error and stderr log
977path when the log was opened. Manifest errors also identify the extension
978directory. Multiline diagnostics preserve their line breaks in both the temporary
979status and scrolling chat.
980If a subprocess exits before sending `hello`, the error also reports its exit
981status or terminating signal.
982
983### TypeScript / Python
984
985These SDKs aren't in the main repo yet; the wire format is small
986enough that a `~30 line` raw script gets you started in either
987language. See the [Quick start](#quick-start) Python example for the
988shape. SDK packages will land in follow-up commits.
989
990## Security
991
992Extensions run with **the user's full filesystem and network
993permissions**. Treat installing an extension the same as installing
994any other binary on your machine.
995
996`zot ext install <git-url>` clones from any URL you give it. There's
997no sandbox in v1; if you need isolation, install only extensions you
998trust or run zot under your platform's sandboxing tool (`bwrap` /
999`sandbox-exec` / AppContainer).
1000
1001## Roadmap
1002
1003Phase 1 (shipped):
1004- [x] subprocess lifecycle + hello handshake
1005- [x] `register_command` + `command_invoked`
1006- [x] `notify` + `clear_notes`
1007- [x] `zot ext` CLI
1008
1009Phase 2 (shipped):
1010- [x] `register_tool` + `tool_call` + `tool_result`
1011- [x] `ready` sentinel for safe agent-registry build timing
1012- [x] tool result attribution surfaces extension name in details
1013
1014Phase 3 (shipped):
1015- [x] event subscriptions (`session_start`, `turn_start`, `turn_end`,
1016 `tool_call`, `assistant_message`)
1017- [x] tool-call interception (block before execution)
1018
1019Phase 4 (shipped):
1020- [x] interception for `turn_start` and `assistant_message` (in
1021 addition to `tool_call`)
1022- [x] modify tool args mid-flight via `modified_args`
1023- [x] rewrite user-visible assistant text via `replace_text`
1024- [x] `/reload-ext` slash command (hot-reload without restarting zot)
1025
1026Future (no firm timeline):
1027- [ ] TypeScript and Python SDK packages (currently the wire format
1028 is stable enough to hand-roll, see the Python quick-start)
1029- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
1030- [ ] per-extension permission scopes (today: full user privileges)