v0.4.10 5ac0009

extensions.md RawHistoryFolder

zot extensions

zot can be extended with custom slash commands by running an external program as a subprocess and exchanging newline-delimited JSON over its stdin/stdout. Extensions can be written in any language that can read and write JSON lines from stdio — Go, TypeScript, Python, Rust, shell with jq, anything.

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:

  1. Project-local: ./.zot/extensions/<name>/extension.json
  2. Global: $ZOT_HOME/extensions/<name>/extension.json

A project-local extension with the same name wins over a global one. 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

  1. Discovery: zot reads every extension.json in the search dirs.
  2. Spawn: enabled extensions are launched as subprocesses. stderr redirects to $ZOT_HOME/logs/ext-<name>.log (one file per extension, append-mode).
  3. Hello handshake: the extension'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.
  4. 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).
  5. 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.
  6. Shutdown: when zot exits, it sends shutdown and waits up to 2s for the extension to send shutdown_ack. Holdouts are SIGTERM'd, then SIGKILL'd.

A crashing extension does not bring down zot. The slash command it owned simply stops working until the extension is fixed and zot is restarted.

Wire format

All frames are one JSON object per line. Top-level type is the discriminator. Optional id correlates request frames with their responses. 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 "call_tool" to opt in to host-mediated calls to built-in and extension tools. The host advertises support in hello_ack.capabilities. Older hosts omit it, and callers must not send call_tool to those hosts.

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.

call_tool

An opt-in extension may request an active tool by name with JSON object args:

{"type":"call_tool","id":"request-1","name":"skill","args":{"name":"example"}}

The host replies with a correlated result:

{"type":"tool_result","id":"request-1","name":"skill",
 "content":[{"type":"text","text":"..."}],"is_error":false}

is_error reports tool or policy failure. Content contains text or base64 image blocks. Results are bounded to 256 KiB and mark truncated: true when shortened. Each request is synchronous for its caller, but multiple requests can be outstanding concurrently if they use distinct IDs. A call_tool_cancel frame with the same ID cancels the request. Calls time out after 60 seconds or when the extension disconnects. Cancellation cannot undo side effects that have already completed.

Calls use the active agent's tool registry, guard interception, and normal confirmation policy. Headless modes retain their normal no-confirmation behavior. A call does not create a model tool-use message in the session transcript. Tool-call, tool-result, confirmation, permission-decision, and tool-interception frames identify these calls through origin_extension. As with model-initiated calls, event subscribers may receive sensitive tool output.

Tool handlers can invoke other tools by setting parent_id to the ID of the host's tool_call frame. The host carries the parent's cancellation context through the nested call, refuses repeated tool names in a chain, and allows at most four tool calls in one chain, including the original tool. Unknown or expired parent IDs fail. To avoid untraceable recursion, requests without parent_id are refused while that extension has a host-invoked tool active. A concurrent command-initiated request from that extension is therefore also refused until its host-invoked tool finishes. Interactive tools can outlive the 60-second request deadline only when invoked by the model, not via call_tool.

The Go SDK exposes e.CallTool(ctx, name, args) and rejects older hosts immediately. A tool handler's context supplies parent_id automatically. Tool failures return ToolResult.IsError, while context and transport failures return a Go error. Extensions have no direct access to another extension's process or built-in implementation.

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.

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.

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

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. 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.
  • "tool_prompt": submits prompt, executes tool_name with JSON-object tool_args through the active agent's normal tool guards and confirmation, then records a paired assistant tool call and tool result before the model turn. The full tool output is stored in the session and visible to the model, so extensions should only request this for content intended to be persistent model context. Tool errors are recorded as tool errors. Cancellation stops before the model turn and preserves any completed call/result pair. This action is supported for interactive slash commands, not spontaneous call_tool requests. The extension does not receive the tool result in the response.
  • "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.

For a skill shortcut, return the request without copying the skill output into the prompt:

{"type":"command_response","id":"...","action":"tool_prompt",
 "prompt":"and push","tool_name":"skill",
 "tool_args":{"name":"developer:commit"}}

Go SDK: return ext.ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args). Unlike CallTool, this executes after the command handler returns. Avoid calling the same tool in the handler or the work will run twice. The recorded assistant call includes origin_extension metadata. Older hosts do not understand the new action and report an unknown action instead of running it. For Gemini 3, zot adds Google's documented skip_thought_signature_validator placeholder to unsigned synthetic function calls when serializing requests. Provider-issued thought signatures are preserved. The extension-to-Gemini request and session replay are covered with a local Gemini-compatible server, not a live Gemini API round trip.

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.

Interactive tools

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.

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

tool_cancel

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.

Tool outcomes and permission decisions

{"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):

  • subprocess lifecycle + hello handshake
  • register_command + command_invoked
  • notify + clear_notes
  • zot ext CLI

Phase 2 (shipped):

  • register_tool + tool_call + tool_result
  • ready sentinel for safe agent-registry build timing
  • tool result attribution surfaces extension name in details

Phase 3 (shipped):

  • event subscriptions (session_start, turn_start, turn_end, tool_call, assistant_message)
  • tool-call interception (block before execution)

Phase 4 (shipped):

  • interception for turn_start and assistant_message (in addition to tool_call)
  • modify tool args mid-flight via modified_args
  • rewrite user-visible assistant text via replace_text
  • /reload-ext slash command (hot-reload without restarting zot)

Future (no firm timeline):

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close