# 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](/docs/extensions) startup section.

## Structured results and codemode

Tools can expose structured JSON results to [Codemode](/docs/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`:

```json
{
  "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"
}
```

```json
{
  "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.

| Exposure | Behavior |
| --- | --- |
| `direct` | Default, advertise a direct tool definition. Callable from scripts too. |
| `codemode` | Callable from scripts and discoverable without an eager direct definition. |
| `deferred` | Discoverable without inline declarations until activated. |
| `model-only` | Not callable from scripts. |

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.

```json
{
  "type": "register_tool",
  "name": "ask_user",
  "description": "Ask the user a question",
  "schema": { "type": "object" },
  "interactive": true
}
```

| Tool kind | Reply timeout | Host support |
| --- | --- | --- |
| Normal tool, `interactive` omitted or false | 60 seconds | Existing extension-enabled hosts |
| Interactive tool | No host-imposed reply deadline | Interactive TUI |

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`:

```go
// 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:

```json
{"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:

```json
{"type":"tool_cancel","id":"invocation-1"}
```

- `id` matches the opaque correlation ID from the host's `tool_call` request. It is not the transcript tool ID or the `tool_id` in 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 `Tool` and `DeferredTool` handlers 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 `interactive` and retain their normal timeout. Both the host and extension need the new functionality for cancellable, unlimited waits. This change does not add a configurable `ToolWithTimeout` API.

## 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:

```json
{"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:

```json
{"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:

```json
{"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.

```json
{"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.

```shell
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 extension
```

## Lifecycle 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:

```json
{
  "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.

| New event | Payload beyond the common fields | Meaning |
| --- | --- | --- |
| `user_prompt_submit` | `text`, optional `queued`, `image_count` | Model input accepted before appending or queueing |
| `tool_result` | `tool_id`, `tool_name`, `tool_args`, `status`, `executed`, `result` | A client tool call reached a terminal outcome |
| `session_end` | `reason` | The active conversation closes |
| `pre_compact` | `compaction_id`, `message_count`, `token_estimate` | A compaction attempt begins |
| `post_compact` | Same counts and ID, `status`, optional `error` | The attempt finishes, including failure or cancellation |
| `subagent_start` | `agent_id`, `agent_run_id`, `name` | A local swarm runner starts or resumes |
| `subagent_stop` | Same identity, `status`, optional `error` | That runner returns |
| `permission_decision` | `tool_id`, `tool_name`, `decision`, `source`, `stage`, optional `reason` | An approval or policy decision was resolved |

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

```json
{
  "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`, or `timed_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_error` is classified as `failed`.
- `executed` means `Tool.Execute` was entered, not that a side effect occurred or was rolled back. A tool-local policy refusal can 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` supports 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:true` marks any omission. This observation limit does not truncate execution output sent to the model or stored in the transcript. Private `Details` and tool activation metadata are not exported.

```json
{
  "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:

```text
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`:

```go
// 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.

> **Best-effort delivery and sensitive content**
>
> Notifications are not a durable or exactly-once audit log. There is no acknowledgement, replay, or crash recovery. Crashes, forced termination, slow consumers, and broken pipes can prevent delivery. Prompts, effective arguments, results, paths, and errors may contain secrets; there is no automatic redaction. Subscribe only to content you need. Subscriptions are not an extension security sandbox.

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:

```json
{"type":"subscribe","intercept":["before_agent_start"]}
```

Before the first agent model call, zot sends a request with the current prompt and effective runtime context:

```json
{
  "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:

```json
{"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. `block` cannot 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_id` is the persisted conversation ID when available, otherwise a runtime-local ID. `agent_run_id` identifies 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.

> **Prompt privacy and permissions**
>
> Subscribed extensions see the entire system prompt, including private context-file instructions, and can remove or replace all of it. This is not a security boundary and does not change tool permissions or validate the safety of replacement instructions.

## 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:

```go
// 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.
