Extensions
Extend zot in any language over JSON-RPC.
zot can be extended in any language via a subprocess plus JSON-RPC protocol. Extensions can register slash commands, expose new tools to the model, replace the assembled system prompt before the first model call, intercept tool calls for permission gates, push status notes (notify / clear_notes), and open interactive extension-owned panels inside the TUI.
Extension command names match case-insensitively, while command_invoked.name uses the canonical spelling the extension registered. Registrations that differ only by case conflict, and the first registration remains active.
Nothing installs automatically. Opt in per machine with zot ext install, or per run with zot --ext ./path while hacking on one. Each extension gets its own directory, so persistent state can live beside the extension itself.
Enable show loaded resources at startup in /settings to list active extension names in the compact Extensions startup section.
Structured results and codemode
Tools can expose structured JSON results to Codemode scripts while keeping normal model-facing text and images. Declare output_schema when registering the tool, then return its JSON value in tool_result.structured_content:
{
"type": "register_tool",
"name": "weather",
"description": "Get synthetic weather data",
"schema": { "type": "object" },
"output_schema": {
"type": "object",
"properties": { "temperature": { "type": "number" } }
},
"namespace": "weather",
"namespace_description": "Weather observations",
"namespace_instructions": "Temperatures are in Celsius",
"exposure": "codemode"
}{
"type": "tool_result",
"id": "invocation-1",
"content": [{ "type": "text", "text": "Temperature: 20 C" }],
"structured_content": { "temperature": 20 },
"is_error": false
}A script can use const result = await tools.weather({}) and inspect result.temperature. Structured results can carry is_error: true, allowing scripts to inspect failures as data. Without both an output schema and structured content, codemode joins text blocks or rejects failed calls. The schema describes the result, it does not validate it.
namespace defaults to the extension name. Optional namespace_description and namespace_instructions participate in discovery ranking and describeNamespace. These fields are additive and retain protocol version 1.
An explicit exposure overrides the legacy deferred flag. Unknown values are rejected. Codemode's only mode hides direct client definitions but keeps deferred definitions available for later activation.
The Go SDK provides StructuredTool(name, description, schema, outputSchema, handler) and ToolResult.StructuredContent. Use ToolWithOptions(name, description, schema, ToolOptions, handler) to combine output schemas, namespace metadata, exposure, and a context-aware handler. ToolOptions includes OutputSchema, Namespace, NamespaceDescription, NamespaceInstructions, Exposure, Deferred, and Interactive. Set Interactive only for tools intentionally waiting on user input, not merely to receive cancellation. Existing tool registration methods remain compatible.
Interactive tools
Register a tool with "interactive": true when it intentionally opens a panel and waits for user input. Its answer remains the result of the original tool call, without polling or injecting a new user turn.
{
"type": "register_tool",
"name": "ask_user",
"description": "Ask the user a question",
"schema": { "type": "object" },
"interactive": true
}Context cancellation, context deadlines, and extension disconnects still end an interactive invocation. Print, JSON, RPC, and other hosts without interactive tool support reject it with a tool error instead of waiting for an invisible panel. Request writes retain a separate bounded transport timeout. Cancellation during a blocked write disconnects the transport because a partially written frame cannot safely be withdrawn, causing other pending calls on that transport to fail too.
Interactive tool handlers and cancellation
The Go SDK exposes InteractiveTool with a context-aware handler. Register it on the ext.Extension returned by ext.New before calling Run:
// ext is github.com/patriceckhart/zot/packages/agent/ext.
e.InteractiveTool("ask_user", "Ask the user a question", schema,
func(ctx context.Context, args json.RawMessage) ext.ToolResult {
// Open a panel with invocation-local state and a unique panel ID.
// Have its callbacks send an answer or resolve panel dismissal.
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, not SDK helpers. Import context and encoding/json for the handler types. Each invocation receives its own context, so a separate OnToolCancel callback is not needed. The handler must release its resources and close its panel when cancelled. Panel dismissal must also resolve its wait. Cancellation is cooperative and cannot stop a handler that ignores its context.
The Go SDK automatically advertises "tool_cancel" in hello.capabilities. Extensions written in other languages opt in through the same handshake:
{"type":"hello","name":"questions","version":"1.0.0","capabilities":["tools","panels","tool_cancel"]}The host sends the following frame best-effort when a delivered invocation is abandoned by cancellation or its reply timeout, but only to extensions advertising that exact capability:
{"type":"tool_cancel","id":"invocation-1"}idmatches the opaque correlation ID from the host'stool_callrequest. It is not the transcript tool ID or thetool_idin lifecycle notifications.- Record cancellation state before dispatching the handler. Cancellation can arrive before its goroutine or worker starts. Unknown or already-completed IDs should be ignored.
- Normal completion does not send a cancellation frame. Late results after timeout or cancellation are safely ignored by the host.
- The SDK cancels active handler contexts on
tool_cancel, shutdown, or host EOF. Other protocol clients must implement their own cleanup, including when cancellation delivery fails after a disconnect. - Existing
ToolandDeferredToolhandlers remain source-compatible. Legacy extensions without"tool_cancel"receive no new cancellation frames. The host still stops waiting and discards late results, but cannot request cleanup of their abandoned work. Missing or unknown capabilities do not enable cancellation notifications. - Older hosts ignore
interactiveand retain their normal timeout. Both the host and extension need the new functionality for cancellable, unlimited waits. This change does not add a configurableToolWithTimeoutAPI.
Calling host tools from an extension
An extension can ask zot to invoke a built-in or another extension's tool without prompting the model to do it. Advertise "call_tool" in the extension's hello.capabilities and check that the host advertises it in hello_ack.capabilities before sending a request:
{"type":"call_tool","id":"request-1","name":"skill","args":{"name":"example"}}zot resolves the name against the active agent's tool registry, applies the same interception and confirmation policy as for a model tool call, and replies with a matching result:
{"type":"tool_result","id":"request-1","name":"skill","content":[{"type":"text","text":"..."}],"is_error":false}is_error reports tool failures, including unknown tools and permission refusals. Content may include text or base64 image blocks. The reply has a 256 KiB and 128-block observation limit and includes truncated:true when shortened. Keep request IDs unique among outstanding calls. Each call has a 60-second deadline, including interactive tools. To cancel one, send {"type":"call_tool_cancel","id":"request-1"}. Cancellation cannot undo completed side effects.
The Go SDK offers [[result, err := e.CallTool(ctx, "skill", map[string]string{"name": "example"})]]. It rejects older hosts immediately. Check result.IsError for tool failures and err for context or transport failures. Calls block their caller, but separate goroutines can make concurrent requests. An extension slash command has its own 30-second response deadline, so give a tool call started from a command a shorter context deadline.
Tool handlers may compose tools by setting parent_id to the host's tool_call.id. The Go SDK supplies it through the handler's context automatically. Nested calls inherit the parent's cancellation, refuse repeated tool names in a chain, and allow at most four tool calls in one chain, including the original call. Unknown or expired parent IDs fail. A request without parent_id is refused while that extension has a host-invoked tool active, which also limits unrelated concurrent command calls from that extension.
In continuous mode, every call_tool needs a live parent_id. Requests without one, including those from slash commands, are refused. Nested calls run as durable tasks on the parent's conversation, so a replayed parent receives earlier results again instead of re-executing completed calls. Continuous hosts also advertise "call_tool_durable" in hello_ack.capabilities and accept an optional key that identifies the call across replays:
{"type":"call_tool","id":"request-1","name":"read","args":{"path":"a"},"parent_id":"parent-1","key":"step-1"}Without a key, calls are numbered in call order. Use keys when a handler issues calls concurrently or in a varying order. Keys are at most 128 bytes and cannot contain / or NUL. A key reused with a different tool or arguments is refused without execution. The 60-second deadline covers execution only, not time spent waiting for an approval. In the Go SDK, pass ext.WithCallKey("step-1") as an extra argument to e.CallTool. The SDK sends the key only to hosts that advertise "call_tool_durable", so older hosts receive unchanged requests.
call_tool does not create model tool-use rows in the session transcript. Tool-call, tool-result, confirmation, permission-decision, and tool-interception frames identify the initiating extension with origin_extension. Headless modes retain their normal no-confirmation behavior. Lifecycle subscribers may receive sensitive tool output. Subscriptions are not a security boundary. Extensions do not receive direct access to another extension process or to built-in tool implementations.
Model-visible tool prompts
Older hosts without tool_prompt support report an unknown command action. Existing call_tool behavior is unchanged.
An interactive extension slash command can return a user prompt and request one tool call before the model continues. Unlike call_tool, the host executes the tool after the command handler returns, so the handler does not receive the result. Do not call the same tool in the handler or it will run twice.
{"type":"command_response","id":"...","action":"tool_prompt","prompt":"and push","tool_name":"skill","tool_args":{"name":"developer:commit"}}Go SDK command handler: [[return ext.ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args)]]. tool_args must be a JSON object, and the prompt and tool name must be non-empty. This action applies to interactive slash commands, not spontaneous call_tool requests or headless hosts.
zot appends the prompt as a user message, executes the tool through the normal guards and confirmation path, then stores a paired assistant tool call and tool result before the model turn. The assistant call records origin_extension. Unknown tools, refusals, and tool failures produce error results that the model can see. Cancellation stops before the model turn and keeps any completed pair valid. The full result persists in the session and becomes model context, so use this only when the output should be retained and shared with the model. It is not limited by the 256 KiB observation cap on call_tool replies.
Gemini 3 requests use Google's documented skip_thought_signature_validator placeholder on unsigned synthetic function calls. This path and session replay are tested with a local Gemini-compatible server, but not against the live Gemini API.
Lifecycle events
Extensions can subscribe to lifecycle notifications, including tool_confirmation_requested. This event fires immediately before zot waits for interactive approval and includes tool_id, tool_name, and the short tool_preview shown in the confirmation dialog. It is not emitted in yolo mode, for remembered approvals, or when a call is blocked before confirmation, and it is notification-only rather than interceptable.
zot ext install ./my-extension # copy into $ZOT_HOME/extensions/
zot ext list # show what's loaded
zot ext logs my-extension -f # tail stderr
zot update # fast-forward every git-installed extensionLifecycle expansion
The protocol remains version 1, and existing interception behavior is unchanged.
Subscribe through events, not intercept. These eight additional events are observational and cannot block, rewrite, or approve an operation:
{
"type": "subscribe",
"events": [
"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"
]
}Notifications carry session_id, cwd, and a monotonically increasing sequence scoped to the extension manager's lifetime. Sequence numbers cover all notifications, not just an extension's subscriptions, so gaps alone do not prove lost delivery. They reset when zot restarts. Unknown fields and event names must remain ignorable.
user_prompt_submit.text is input after host preprocessing, not raw keystrokes. It includes extension-generated prompts. Queued follow-ups emit once at acceptance with queued:true, not again at consumption; they may later be withdrawn without reaching the model. image_count reports attachments without exposing image bytes. Transcript replay, internal image mirrors, compaction summaries, and slash commands that do not submit model input do not emit this event.
Tool outcomes and permissions
Correlate a result with (session_id, tool_id). The existing tool_call notification contains the model's original arguments; tool_result.tool_args contains the effective arguments after accepted interceptor rewrites, including the empty-argument default {}. Before-execution refusals report the last proposed arguments instead. Malformed JSON is preserved as tool_args_raw, with tool_args omitted, so the event remains a valid frame.
{
"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
}
}- Tool status is
completed,failed,blocked,cancelled, ortimed_out. Unknown tools, panics, and ordinary error results are failures. Guards and built-in jail/manifest policy refusals are blocked outcomes. - Cancellation and timeout classification uses typed execution errors or explicit tool outcome metadata, not error-message matching. A custom tool reporting only
is_erroris classified asfailed. executedmeansTool.Executewas entered, not that a side effect occurred or was rolled back. A tool-local policy refusal can beblockedwithexecuted:true. Pending calls cancelled before execution still receive terminal results. Provider-executed server tools do not receive clienttool_resultevents.result.contentsupports text and base64 image blocks, with a combined 256 KiB 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:truemarks any omission. This observation limit does not truncate execution output sent to the model or stored in the transcript. PrivateDetailsand tool activation metadata are not exported.
{
"type": "event",
"event": "permission_decision",
"session_id": "session-1",
"cwd": "/work",
"sequence": 11,
"tool_id": "call-1",
"tool_name": "bash",
"decision": "approved",
"source": "user",
"stage": "pre_execution"
}Permission decisions are approved, denied, or cancelled; sources are user, remembered, policy, or yolo. Automatic approval is an approved decision with its source, not a separate decision value. Guard refusals, preview failures, and no available confirmer use policy. Existing mode-specific confirmation behavior is unchanged.
A pre_execution approval does not bypass tool-local checks. A subsequent policy refusal emits another denied decision with source:"policy" and stage:"tool_execution" before the blocked result. Calls cancelled before permission checking or rejected as unknown tools can have a result without a permission decision. Observing these events cannot grant permission or override a refusal.
Session, compaction, and swarm boundaries
session_start and session_end bracket an active conversation, not just extension process setup. They use the persisted conversation ID when available, otherwise a runtime-generated ID. Interactive and RPC sessions start when initialized. Single-shot and headless sessions start with the agent's first observation. Switching conversations, including /new, emits the old end before the new start. Current end reasons are shutdown, session_switch, and cwd_change, with at most one end per activation on those graceful paths. Model changes and clearing messages do not close the active conversation. Extension reload does not close it or replay earlier events.
Every compaction attempt has a paired post_compact with the same compaction_id, including empty transcripts, failures, and cancellation. Status is completed, failed, cancelled, or timed_out. Post counts describe the resulting in-memory transcript, not guaranteed persistence. Token estimates count serialized text bytes divided by four; they are not exact provider usage or image token counts. These events cannot modify the summary.
Swarm notifications come from the parent supervisor. session_id identifies the task's parent conversation even after the active conversation changes. agent_id survives resume, while agent_run_id is fresh for each local runner invocation and pairs start with stop. name currently uses the agent ID. Stop status is completed, failed, cancelled, or timed_out. Loading detached agents does not replay events; resuming one starts a new observable run. Cancellation emits stop only when the runner returns. Interactive shutdown waits up to two seconds for local runners before closing extensions, so unresponsive runners may not produce a final notification.
Lifecycle delivery and Go SDK
The existing turn_end boundary is preserved: it ends the model response attempt, before client tool execution. A normal client tool step has this order:
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)Multiple calls are announced before their batch executes. Retries, blocked turns, queued prompts, and concurrent swarm tasks can add or interleave events; do not treat the example as a globally linear workflow.
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 of 256 frames or 16 MiB of queued bytes, plus one in-flight frame. Overflow disconnects the consumer instead of silently dropping an event and continuing. Synchronous writes are bounded to five seconds. Graceful shutdown attempts to drain queued events, including session_end, before sending shutdown, within the shutdown grace period. The protocol's 4 MiB frame limit still applies.
Register Go handlers on the ext.Extension returned by ext.New, before Run:
// ext is github.com/patriceckhart/zot/packages/agent/ext.
e.On("tool_result", func(event ext.Event) {
if event.Result == nil {
return
}
// Enqueue selected metadata for your own background worker.
// Check event.Result.Truncated before treating content as complete.
})The SDK exposes the new payload fields through ext.Event and ext.EventResult. Event.Name is the event name; Event.AgentName holds a swarm runner's wire-level name. Executed, MessageCount, and TokenEstimate are pointers so absent fields differ from false or zero. Event handlers run synchronously on the extension's read goroutine; lengthy work should go to an extension-owned bounded queue so it does not delay later interception requests.
This expansion applies 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.
System prompt replacement
Subscribe to before_agent_start to inspect and replace the complete system prompt after zot assembles its base instructions, context files, skills, and tool descriptions:
{"type":"subscribe","intercept":["before_agent_start"]}Before the first agent model call, zot sends a request with the current prompt and effective runtime context:
{
"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"
}Return the full replacement, not a fragment for the host to append:
{"type":"event_intercept_response","id":"start-1","system_prompt":"The complete replacement system prompt"}- zot uses the returned string exactly, without trimming, merging, or appending. An empty string removes the prompt; an omitted field keeps it unchanged.
blockcannot block startup. - Subscribed extensions run in successful load order, explicit extensions before discovery. Each receives the previous extension's result.
- The result is reused across ordinary user messages, retries, and tool-loop steps. The hook runs again after a model or session change, conversation clear, or explicit prompt rebuild/reset, including interactive extension reload.
- Rebuilds start from the unmodified base prompt. If a reset happens while a hook waits, zot discards the stale preparation and runs the chain again, even when the reset keeps the same text. Cancellation does not commit a pending replacement.
session_idis the persisted conversation ID when available, otherwise a runtime-local ID.agent_run_ididentifies one preparation pass, is shared by its extension chain, and changes on each rebuild.
session_start is observational, not a system-prompt replacement hook. The lifecycle events above pair it with session_end for active conversations. turn_start can guard individual model calls, but does not replace the system prompt. before_agent_start is supported in interactive, print, JSON, and RPC modes; RPC warnings arrive as ext_notify events.
Each extension has a five-second deadline, including the request pipe write. Invalid/non-string values, malformed responses, crashes, and timeouts keep the current prompt and produce a log diagnostic and host warning. Omission is a normal no-op. The encoded system_prompt response value is limited to 1 MiB, and complete response frames to 4 MiB. An oversized frame disconnects the extension's read loop; a blocked request write closes its input pipe to release the writer.
Go SDK prompt hook
Register the hook on the ext.Extension returned by ext.New before calling Run. Return nil to keep the prompt, or a pointer to an empty string to remove it:
// ext is github.com/patriceckhart/zot/packages/agent/ext.
e.InterceptBeforeAgentStart(func(event ext.BeforeAgentStartEvent) *string {
prompt := event.SystemPrompt + "\nUse the project's preferred coding style."
return &prompt
})Reload and diagnose
Run /reload-ext after editing an extension to stop and reload discovered extensions plus explicit --ext paths without restarting zot. Load failures from startup and reload stay in the scrolling chat until a successful reload or /clear, but are never sent to the model or saved in the session transcript.
Startup and handshake failures include the exact extension stderr log path. If an extension exits before sending hello, the error also reports its exit status or terminating signal. Use zot ext logs <name> -f to follow that log while debugging.
Reference implementations live under examples/extensions/ in Go, TypeScript, and Node. Browse community extensions on the Extensions page, or read the full protocol spec on GitHub.