v0.48.1 deaab59

extensions.md RawHistoryFolder

zut extensions

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

Six phases shipped so far:

  • Phase 1: slash commands, chat notifications, and host alerts.
  • 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 zut.
  • Phase 5: branch-aware session lifecycle events and opaque extension state persisted with session files.
  • Phase 6: hidden per-turn context, persistent status/widgets, and extension-bundled skills.
  • 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
# $ZUT_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","protocol_version":2,"name":"hello-py","version":"1.0.0","capabilities":["commands"]})

for line in sys.stdin:
    msg = json.loads(line)
    if msg["type"] == "hello_ack":
        if msg.get("protocol_version") != 2:
            raise SystemExit("unsupported extension protocol version")
        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 zut does not spawn a subprocess.

chmod +x hello.py, install:

zut ext install ./hello-py

Restart zut, type /hellopy, the agent greets you. Done.

Built-in extensions

zut ships with no extensions installed by default. A fresh zut 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:

# install a Go example and explicitly build it in the staged install
zut ext install --build=go path/to/zut/examples/extensions/hello

# or build it in the source tree for a one-run development load
cd path/to/zut/examples/extensions/hello
go build -o hello .
zut --ext .

zut ext install never builds source code implicitly. For local installs it validates extension.json and any executable path relative to the extension directory, then stages the copy so a missing or ignored runtime artifact fails with an explicit error instead of becoming a broken installed extension. For a local Go extension, opt into the fixed builder explicitly with zut ext install --build=go <path>. The builder runs go build -trimpath from the source directory, writes the manifest-declared relative executable into the staging directory, and validates it before installation. It may resolve Go modules and access the network according to the user's Go environment. Bare launchers such as go, node, and npx remain resolved from PATH when zut starts; they cannot be used as the output of --build=go.

Nothing is auto-installed and nothing reaches out to the network without your explicit action.

Layout & discovery

zut scans two directories on startup, in this order:

  1. Project-local: ./.zut/extensions/<name>/extension.json
  2. Global: $ZUT_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, $ZUT_HOME defaults to $XDG_STATE_HOME/zut. Otherwise it defaults to ~/Library/Application Support/zut/ on macOS, ~/.local/state/zut on Linux, or %LOCALAPPDATA%\zut 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 zut how to launch it:

{
  "name": "weather",
  "version": "1.0.0",
  "exec": "./weather",
  "args": ["--mode", "daemon"],
  "language": "go",
  "description": "current weather for any city",
  "skills": ["skills"],
  "enabled": true
}
field meaning
name required. how zut identifies the extension; must match what's sent in the hello frame.
version optional. shown in zut 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 zut ext list.
skills optional. relative directories containing bundled <name>/SKILL.md files or a direct SKILL.md. Paths must remain inside the extension directory.
enabled optional, defaults to true. set to false to disable without removing.

Lifecycle

  1. Discovery: zut reads every extension.json in the search dirs.
  2. Spawn: enabled extensions are launched as subprocesses. stderr redirects to $ZUT_HOME/logs/ext-<name>.log (one file per extension, append-mode).
  3. Hello handshake: the extension's first stdout frame must be hello with protocol_version: 2; zut rejects a mismatched major and replies with hello_ack containing the same 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, zut dispatches command_invoked frames when the user runs a registered command; the extension responds with command_response. Extensions can also push notify, persistent status/widget, and structured alert frames during runtime. Extensions subscribed to session lifecycle events receive the active branch identity and their own restored opaque state. Panel-capable extensions may open an interactive panel, receive key events, and push redraws while the panel is focused.
  6. Shutdown: when zut 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 zut. The slash command it owned simply stops working until the extension is fixed and zut 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, alert, logs, or any other stdout frame before hello.

Extension → host

hello (required, first frame)

{"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0",
 "capabilities":["commands","tools","alerts","panels"]}

Include "tool_cancel" in capabilities to opt in to host cancellation notifications for abandoned tool calls (see tool_cancel below). The Go SDK advertises it automatically. The host only sends tool_cancel to extensions that advertised that exact capability; extensions that omit it never receive the frame and otherwise behave as before.

register_command

{"type":"register_command","name":"weather",
 "description":"current weather for a city"}

Command names are matched case-insensitively. zut 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, python, worktree, grep, ast, lsp, web_search, web_open, web_find, web_click, schedule, update_goal, plan, skill). Conflicts with active built-ins are silently shadowed by the built-in. Every native public-web capability name, plus grep, ast, python, schedule, update_goal, and plan, is reserved even when it is unavailable, so an extension cannot claim or replace it.

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, zut 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.

Interactive tools

Set "interactive": true on register_tool for a tool that intentionally waits for a person, for example one that opens a panel and returns once the user answers. Omitted or false keeps the normal 60-second reply timeout.

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

An interactive tool has no host-imposed reply deadline. The call still ends when the agent context is cancelled (user abort, turn deadline), when the extension disconnects, or when zut shuts down; each of those surfaces a tool error to the model. Only the interactive terminal host can run interactive tools. Print, JSON, RPC, and bot hosts reject the call with a tool error instead of waiting on an invisible panel. Scheduled background sessions exclude interactive tools from their catalog, even when the shared extension manager is attached to the foreground TUI. Resident subagents also exclude them and reject a declared interactive tool during assembly. Ordinary extension tools retain their existing mode-specific availability.

Interactive tools also run from /btw and an interactive conversation mirrored to Telegram, but their panels appear only in the local TUI, not in Telegram. A Telegram-only user must use /stop to cancel a waiting main-turn call; /btw calls are cancelled with Escape in the local TUI. Panels use one shared slot: opening another panel replaces the visible one without notifying the previous owner. Extension handlers must honor cancellation and must not rely on simultaneous panels.

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

schema, answers, and closeQuestionPanel are extension-owned values. Panel dismissal must also resolve the handler's wait. Cancellation is cooperative: the SDK cancels the handler's ctx on tool_cancel, on shutdown, and when the host connection ends, and it discards a result the handler returns after cancellation, but it cannot stop a handler that ignores ctx. Existing Tool and DeferredTool handlers are unchanged and remain source-compatible.

The interactive field is additive and does not change the protocol version. Older hosts ignore it and apply their normal timeout. Both host and extension must support the feature for a cancellable, unlimited wait.

ready

Sentinel telling zut "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"}],
 "details":{"state":{"version":1}}}

details is opaque extension metadata. Zut excludes it from provider requests and persists it as the latest extension state for the active session branch. Keep it valid JSON and reasonably small; the host caps persisted snapshots at 256 KiB. The next session_opened, session_switched, session_forked, or session_compacted event returns the extension's own snapshot in state.

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 include session_start, session_opened, session_switched, session_forked, session_compacted, turn_start, tool_call, tool_confirmation_requested, assistant_message, and turn_end.

Session events contain session with the current branch ID, parent ID, path, cwd, and fork point. They also contain state, but only for the receiving extension. Zut never exposes another extension's state.

tool_confirmation_requested fires only when zut 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:

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

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. Rewrites replace the complete argument object; for bash, preserve the required positive integer timeout when changing command.
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.
context for turn_start: bounded hidden context sent only in the upcoming provider request. It is not a user message and is not persisted in the transcript.

Missing the response within 5s is treated as "allow" (i.e. an unresponsive extension never stalls the agent). When multiple extensions subscribe to the same event, they're consulted serially; the first block wins and rewrites (args / text) chain: each subsequent interceptor sees the previous one's output.

{"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","timeout":30}}

{"type":"event_intercept_response","id":"...",
 "replace_text":"[redacted]"}

{"type":"event_intercept_response","id":"...",
 "context":"Current phase: parse files\nRemaining: validate inputs"}

command_response (reply to command_invoked)

{"type":"command_response","id":"...","action":"prompt",
 "prompt":"Show today's weather for Berlin in one line."}

action is one of:

  • "prompt" — submits prompt as a fresh user message; the agent runs a turn against it.
  • "insert" — inserts insert into the editor at the cursor without submitting.
  • "display" — appends display to the chat as a one-shot styled note. No model call, nothing written to the transcript.
  • "open_panel" — opens an extension-owned interactive panel inside zut. The panel content lives in open_panel.
  • "noop" — the extension handled it itself (e.g. it pushed notify frames or kicked off background work). zut doesn't change the UI in response.

Example:

{"type":"command_response","id":"...","action":"open_panel",
 "open_panel":{
   "id":"todos-main",
   "title":"Todos",
   "lines":["□ ship panel api","✓ persist state"],
   "footer":"↑/↓ navigate - a add - x complete - esc close"
 }}

If error is non-empty, zut 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, zut starts a turn immediately. If a turn is already running, zut 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"}

status (one-way, persistent)

Sets or replaces one status item owned by the extension. Sending an empty text clears the item.

{"type":"status","key":"progress","level":"success",
 "text":"2/4 tasks checked"}

widget (one-way, persistent)

Sets or replaces a compact widget. Interactive zut renders widgets in the persistent chrome above the editor. Use "above_input" as the position; empty and unknown positions resolve to the same placement. Use open_panel for interaction or navigation.

{"type":"widget","id":"progress","position":"above_input",
 "title":"Progress","lines":["Indexing files","12/20 complete"]}

widget_clear (one-way)

{"type":"widget_clear","id":"plan"}

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

alert (one-way, any time)

Requests a host-owned structured alert. The first supported kind is bell, which asks the interactive terminal to emit one terminal bell. reason is semantic metadata and is not rendered as terminal text. The shared terminal-alert setting in /settings applies to extension alerts too; disabled or non-interactive hosts may ignore the request.

{"type":"alert","kind":"bell","reason":"question_ready"}

The Go SDK sends the same frame with:

e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"})

Extensions must not write BEL, ANSI, or other terminal bytes to stdout; stdout is reserved for protocol frames. In --mode rpc, this becomes an ext_alert JSON event for the RPC client to interpret.

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":2,
 "zut_version":"0.0.7","provider":"anthropic",
 "model":"claude-opus-4-7","cwd":"/path/to/zut",
 "extension_dir":"/path/to/zut/.zut/extensions/todos",
 "data_dir":"/path/to/zut/.zut/extensions/todos",
 "session":{"id":"branch-1","parent_id":"root-1",
             "path":".../session.jsonl","cwd":"/path/to/zut",
             "fork_point":4}}

Sent immediately after a matching hello. The protocol version is a major and must match the version sent by the extension. 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.

tool_call

Sent when the LLM invokes a tool the extension registered. args is the parsed JSON object the model produced; the extension is responsible for validating/coercing it.

{"type":"tool_call","id":"...","name":"weather",
 "args":{"city":"Berlin"}}

Reply with tool_result within the host's tool timeout (default 60s). Missing the timeout surfaces an error to the model and the call is marked as failed. Tools registered with "interactive": true have no reply timeout (see Interactive tools above).

The request write itself is always bounded, independently of the reply deadline: host frames are queued in order, synchronous writes have a five-second timeout, and a write timeout disconnects the extension. The queue is bounded to 256 frames and 16 MiB; overflowing it disconnects the extension rather than dropping frames silently. If the agent cancels a call while its tool_call frame is still queued, the frame is withdrawn and the transport stays healthy. If writing has begun, the host allows up to 250 ms for that frame to finish, then disconnects if it is still blocked: a torn frame cannot be withdrawn, and other pending calls on that extension fail too.

Every tool_call carries a correlation id that is unique within the host process. A tool_result for an id the host no longer waits on (timed out, cancelled, or already answered) is ignored.

tool_cancel

Sent best-effort when a delivered tool_call is abandoned because the agent context was cancelled or the reply timeout elapsed, and only to extensions that advertised "tool_cancel" in hello.capabilities. Normal completion never sends it. id is the tool_call correlation id, not a transcript tool id.

{"type":"tool_cancel","id":"..."}

Stop the associated work, close any panel it opened, and discard its pending state. Ignore unknown or already-completed ids. tool_cancel may arrive before your handler goroutine has started, so register cancellation state before dispatching work. Delivery is not guaranteed after a transport disconnect; extensions must also clean up on shutdown and on stdin EOF. The host 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.

{"type":"event","event":"session_opened",
 "session":{"id":"branch-1","parent_id":"root-1","fork_point":4},
 "state":{"version":1}}
{"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"}

event_intercept

Sent when zut 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","timeout":30}}

// 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 zut (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 zut exit (or /reload-ext once that lands). Reply with shutdown_ack and then exit.

Managing extensions from the CLI

zut ext list                              list installed extensions and their state
zut ext doctor                            diagnose load, registration, and conflict issues
zut ext install <path|git-url>             copy / clone into $ZUT_HOME/extensions/
zut ext install --build=go <local-path>   build local Go source, then install
zut ext remove <name>                     delete an extension directory
zut ext enable <name>           re-enable a disabled extension
zut ext disable <name>          disable without removing
zut ext logs <name> [-f]        cat / tail the extension's stderr

zut ext doctor runs the same discovery path as zut 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.

zut 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. No build runs unless the local install is invoked with the explicit --build=go option. That option currently supports only local Go source paths; clone remote source locally before building it.

Loading an extension for one run

For iteration on a working copy, skip the install + reload cycle and load straight from disk for one zut session:

zut --ext ./my-extension        # short form: -e ./my-extension
zut --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 zut 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/bnema/zut/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 $ZUT_HOME/extensions/hello/.

OnHello is optional. Use it when configuration or registrations need host metadata such as HostInfo.CWD, Provider, Model, ZutVersion, ExtensionDir, or DataDir. The SDK sends hello, waits for hello_ack, runs OnHello, announces registrations, then sends ready.

Session-aware extensions can subscribe to session_opened, session_switched, session_forked, and session_compacted; Event.Session identifies the active branch and Event.State contains that extension's latest persisted snapshot. A tool can return opaque state with result.Details = ext.JSONDetails(value).

TurnStartDecision.Context supplies hidden bounded context for the next model request. SetStatus, SetWidget, and their clear methods update persistent interactive chrome without entering the transcript.

For Go extensions, use the SDK placement constant:

e.SetWidget("progress", ext.WidgetPositionAboveInput, "Progress", lines)
e.ClearWidget("progress")

The SDK has four interceptor hooks, all optional:

// e is the *ext.Extension returned by ext.New(...).

// 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","timeout":30}`),
    }
})

// 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 zut. 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 the /reload-ext status and scrolling chat, and in zut ext doctor output. 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.

zut 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 zut 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 + structured terminal alerts
  • zut 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, session_opened, session_switched, session_forked, session_compacted, 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 zut)

Phase 5 (shipped):

  • session/branch identity in hello_ack and lifecycle events
  • session open/switch/fork/compaction notifications
  • opaque extension state persisted with session branches

Phase 6 (shipped):

  • hidden bounded context returned from turn_start interception
  • persistent extension status and widget frames
  • manifest-declared bundled skill discovery with safe path validation

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# zut extensions
2
3zut 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
9Six phases shipped so far:
10
11- **Phase 1**: slash commands, chat notifications, and host alerts.
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 zut.
16- **Phase 5**: branch-aware session lifecycle events and opaque extension
17  state persisted with session files.
18- **Phase 6**: hidden per-turn context, persistent status/widgets, and
19  extension-bundled skills.
20- **Theme-only extensions**: ship `theme.json` without launching a
21  subprocess. See [themes.md](themes.md).
22
23## Quick start
24
25The simplest extension is a script that prints a hello frame, reads
26commands, and prints responses. Here's the whole thing in **Python**,
27no SDK required:
28
29```python
30#!/usr/bin/env python3
31# $ZUT_HOME/extensions/hello-py/hello.py
32import json, sys
33
34def emit(obj):
35    sys.stdout.write(json.dumps(obj) + "\n")
36    sys.stdout.flush()
37
38emit({"type":"hello","protocol_version":2,"name":"hello-py","version":"1.0.0","capabilities":["commands"]})
39
40for line in sys.stdin:
41    msg = json.loads(line)
42    if msg["type"] == "hello_ack":
43        if msg.get("protocol_version") != 2:
44            raise SystemExit("unsupported extension protocol version")
45        emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
46        emit({"type":"ready"})
47    elif msg["type"] == "command_invoked":
48        emit({"type":"command_response","id":msg["id"],"action":"prompt",
49              "prompt": "Greet me very briefly in one sentence."})
50    elif msg["type"] == "shutdown":
51        emit({"type":"shutdown_ack"})
52        break
53```
54
55Drop it in a directory with this `extension.json`:
56
57```json
58{
59  "name": "hello-py",
60  "version": "1.0.0",
61  "exec": "./hello.py",
62  "language": "python",
63  "enabled": true
64}
65```
66
67`exec` is required for protocol extensions. If an extension only ships
68`theme.json` or `themes/theme.json`, no `exec` is required and zut does
69not spawn a subprocess.
70
71`chmod +x hello.py`, install:
72
73```bash
74zut ext install ./hello-py
75```
76
77Restart `zut`, type `/hellopy`, the agent greets you. Done.
78
79## Built-in extensions
80
81**zut ships with no extensions installed by default.** A fresh `zut 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.
82
83The `examples/extensions/` directory in the repo is reference code, not a default install set. To use any of those:
84
85```bash
86# install a Go example and explicitly build it in the staged install
87zut ext install --build=go path/to/zut/examples/extensions/hello
88
89# or build it in the source tree for a one-run development load
90cd path/to/zut/examples/extensions/hello
91go build -o hello .
92zut --ext .
93```
94
95`zut ext install` never builds source code implicitly. For local installs it
96validates `extension.json` and any executable path relative to the extension
97directory, then stages the copy so a missing or ignored runtime artifact fails
98with an explicit error instead of becoming a broken installed extension. For a
99local Go extension, opt into the fixed builder explicitly with
100`zut ext install --build=go <path>`. The builder runs `go build -trimpath` from
101the source directory, writes the manifest-declared relative executable into the
102staging directory, and validates it before installation. It may resolve Go
103modules and access the network according to the user's Go environment. Bare
104launchers such as `go`, `node`, and `npx` remain resolved from `PATH` when zut
105starts; they cannot be used as the output of `--build=go`.
106
107Nothing is auto-installed and nothing reaches out to the network without your explicit action.
108
109## Layout & discovery
110
111zut scans two directories on startup, in this order:
112
1131. **Project-local**: `./.zut/extensions/<name>/extension.json`
1142. **Global**: `$ZUT_HOME/extensions/<name>/extension.json`
115
116A project-local extension with the same name wins over a global one.
117When `XDG_STATE_HOME` is set on any platform, `$ZUT_HOME` defaults to
118`$XDG_STATE_HOME/zut`. Otherwise it defaults to `~/Library/Application Support/zut/`
119on macOS, `~/.local/state/zut` on Linux, or `%LOCALAPPDATA%\zut` on Windows.
120
121Because each extension owns its own directory, the recommended place
122for extension state is inside that directory itself (for example
123`todos.json`, `settings.json`, or an auth/cache file used only by that
124extension). The host also passes this path back in `hello_ack` as
125`extension_dir` / `data_dir` so runtime code does not need to guess it.
126
127Each extension owns its own subdirectory. The `extension.json`
128manifest tells zut how to launch it:
129
130```json
131{
132  "name": "weather",
133  "version": "1.0.0",
134  "exec": "./weather",
135  "args": ["--mode", "daemon"],
136  "language": "go",
137  "description": "current weather for any city",
138  "skills": ["skills"],
139  "enabled": true
140}
141```
142
143| field | meaning |
144|---|---|
145| `name` | required. how zut identifies the extension; must match what's sent in the `hello` frame. |
146| `version` | optional. shown in `zut ext list`. |
147| `exec` | required. path to the executable (relative to the manifest). |
148| `args` | optional. extra argv passed to `exec`. |
149| `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
150| `description` | optional. shown in `zut ext list`. |
151| `skills` | optional. relative directories containing bundled `<name>/SKILL.md` files or a direct `SKILL.md`. Paths must remain inside the extension directory. |
152| `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
153
154## Lifecycle
155
1561. **Discovery**: zut reads every `extension.json` in the search dirs.
1572. **Spawn**: enabled extensions are launched as subprocesses. stderr
158   redirects to `$ZUT_HOME/logs/ext-<name>.log` (one file per
159   extension, append-mode).
1603. **Hello handshake**: the extension's first stdout frame must be
161   `hello` with `protocol_version: 2`; zut rejects a mismatched major
162   and replies with `hello_ack` containing the same protocol version,
163   the active provider/model/cwd, and the extension's own data directory
164   so it can persist files beside its manifest.
1654. **Registration**: after receiving `hello_ack`, the extension sends
166   `register_command`, `register_tool`, and subscription frames, then
167   sends `ready`. First-come-first-served: a name already taken by a
168   built-in or by a previously-loaded extension is silently shadowed
169   (logged in the extension's own log file).
1705. **Runtime**: after `ready`, zut dispatches `command_invoked` frames
171   when the user runs a registered command; the extension responds
172   with `command_response`. Extensions can also push `notify`, persistent
173   status/widget, and structured `alert` frames during runtime. Extensions
174   subscribed to session lifecycle events receive the active branch identity
175   and their own restored opaque state. Panel-capable extensions may open an
176   interactive panel, receive key events, and push redraws while the panel is
177   focused.
1786. **Shutdown**: when zut exits, it sends `shutdown` and waits up to
179   2s for the extension to send `shutdown_ack`. Holdouts are
180   SIGTERM'd, then SIGKILL'd.
181
182A crashing extension does not bring down zut. The slash command it
183owned simply stops working until the extension is fixed and zut is
184restarted.
185
186## Wire format
187
188All frames are one JSON object per line. Top-level `type` is the
189discriminator. Optional `id` correlates request frames with their
190responses. The canonical startup order is `hello`, `hello_ack`,
191registration frames, then `ready`. Do not send `notify`, `alert`, logs,
192or any other stdout frame before `hello`.
193
194### Extension → host
195
196#### `hello` (required, first frame)
197
198```json
199{"type":"hello","protocol_version":2,"name":"weather","version":"1.0.0",
200 "capabilities":["commands","tools","alerts","panels"]}
201```
202
203Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
204notifications for abandoned tool calls (see `tool_cancel` below). The Go
205SDK advertises it automatically. The host only sends `tool_cancel` to
206extensions that advertised that exact capability; extensions that omit it
207never receive the frame and otherwise behave as before.
208
209#### `register_command`
210
211```json
212{"type":"register_command","name":"weather",
213 "description":"current weather for a city"}
214```
215
216Command names are matched case-insensitively. zut sends `command_invoked.name` using the canonical spelling registered here. Registrations that differ only by case conflict, and the first registration remains active.
217
218#### `register_tool`
219
220Registers a tool the LLM can call. `schema` is a JSON Schema object
221describing the tool's args (the same shape Anthropic and OpenAI accept).
222
223```json
224{"type":"register_tool","name":"weather",
225 "description":"Get the current weather for a city.",
226 "schema":{
227   "type":"object",
228   "properties":{"city":{"type":"string"}},
229   "required":["city"]
230 }}
231```
232
233Tool names live in the same namespace as built-in tools (`read`,
234`write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,
235`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `plan`,
236`skill`). Conflicts with active built-ins are silently shadowed by the built-in.
237Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
238`update_goal`, and `plan`, is reserved even when it is unavailable, so an
239extension cannot claim or replace it.
240
241Set `"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`:
242
243```json
244{"type":"tool_result","id":"...",
245 "content":[{"type":"text","text":"Enabled weather lookup"}],
246 "activate_tools":["weather"]}
247```
248
249On Kimi K3's OpenAI-compatible routes, zut 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.
250
251##### Interactive tools
252
253Set `"interactive": true` on `register_tool` for a tool that intentionally
254waits for a person, for example one that opens a panel and returns once the
255user answers. Omitted or `false` keeps the normal 60-second reply timeout.
256
257```json
258{"type":"register_tool","name":"ask_user",
259 "description":"Ask the user a question","schema":{"type":"object"},
260 "interactive":true}
261```
262
263An interactive tool has no host-imposed reply deadline. The call still ends
264when the agent context is cancelled (user abort, turn deadline), when the
265extension disconnects, or when zut shuts down; each of those surfaces a tool
266error to the model. Only the interactive terminal host can run interactive
267tools. Print, JSON, RPC, and bot hosts reject the call with a tool error instead
268of waiting on an invisible panel. Scheduled background sessions exclude
269interactive tools from their catalog, even when the shared extension manager is
270attached to the foreground TUI. Resident subagents also exclude them and reject
271a declared interactive tool during assembly. Ordinary extension tools retain
272their existing mode-specific availability.
273
274Interactive tools also run from `/btw` and an interactive conversation mirrored
275to Telegram, but their panels appear only in the local TUI, not in Telegram.
276A Telegram-only user must use `/stop` to cancel a waiting main-turn call;
277`/btw` calls are cancelled with Escape in the local TUI. Panels use one shared
278slot: opening another panel replaces the visible one without notifying
279the previous owner. Extension handlers must honor cancellation and must not
280rely on simultaneous panels.
281
282The Go SDK provides a context-aware handler:
283
284```go
285e.InteractiveTool("ask_user", "Ask the user a question", schema,
286    func(ctx context.Context, args json.RawMessage) ext.ToolResult {
287        // Open your panel and arrange for its callbacks to deliver an answer.
288        // Use invocation-local state, including a unique panel ID.
289        defer closeQuestionPanel()
290        select {
291        case answer := <-answers:
292            return ext.TextResult(answer)
293        case <-ctx.Done():
294            return ext.TextErrorResult("question cancelled")
295        }
296    })
297```
298
299`schema`, `answers`, and `closeQuestionPanel` are extension-owned values.
300Panel dismissal must also resolve the handler's wait. Cancellation is
301cooperative: the SDK cancels the handler's `ctx` on `tool_cancel`, on
302`shutdown`, and when the host connection ends, and it discards a result the
303handler returns after cancellation, but it cannot stop a handler that ignores
304`ctx`. Existing `Tool` and `DeferredTool` handlers are unchanged and remain
305source-compatible.
306
307The `interactive` field is additive and does not change the protocol
308version. Older hosts ignore it and apply their normal timeout. Both host and
309extension must support the feature for a cancellable, unlimited wait.
310
311#### `ready`
312
313Sentinel telling zut "all initial registrations are flushed". Send it
314right after your last `register_*` frame so the host can build the
315agent's tool registry without racing the registration window.
316
317```json
318{"type":"ready"}
319```
320
321#### `tool_result`
322
323Reply to a `tool_call` from the host. `content[]` is a list of
324message blocks; each block is `{"type":"text","text":"..."}` or
325`{"type":"image","mime_type":"image/png","data":"<base64>"}`. Set
326`is_error: true` to mark the call as failed. `activate_tools` can name
327registered deferred tools that become available after this result.
328
329```json
330{"type":"tool_result","id":"...",
331 "content":[{"type":"text","text":"Berlin: 16°C, fog"}],
332 "details":{"state":{"version":1}}}
333```
334
335`details` is opaque extension metadata. Zut excludes it from provider
336requests and persists it as the latest extension state for the active
337session branch. Keep it valid JSON and reasonably small; the host caps
338persisted snapshots at 256 KiB. The next `session_opened`,
339`session_switched`, `session_forked`, or `session_compacted` event returns
340the extension's own snapshot in `state`.
341
342#### `subscribe`
343
344Declares which lifecycle events the extension wants to observe and
345which it wants to intercept. Send once after `hello`, before `ready`.
346
347```json
348{"type":"subscribe",
349 "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"],
350 "intercept":["tool_call","turn_start","assistant_message"]}
351```
352
353Recognised event names include `session_start`, `session_opened`,
354`session_switched`, `session_forked`, `session_compacted`, `turn_start`,
355`tool_call`, `tool_confirmation_requested`, `assistant_message`, and
356`turn_end`.
357
358Session events contain `session` with the current branch ID, parent ID,
359path, cwd, and fork point. They also contain `state`, but only for the
360receiving extension. Zut never exposes another extension's state.
361
362`tool_confirmation_requested` fires only when zut is about to wait for
363interactive approval. Calls running in yolo mode, calls covered by a
364remembered approval, and calls blocked before confirmation do not emit it.
365The event includes `tool_id`, `tool_name`, and the short `tool_preview`
366shown in the confirmation dialog.
367
368Interceptable events:
369
370- `tool_call`: block the call (model sees `reason` as the tool
371  error) or rewrite args via `modified_args`.
372- `turn_start`: block the turn before the model is called. Useful
373  for rate-limiting and business-hour gates. `reason` is shown to
374  the user as a status line. No rewrite supported.
375- `assistant_message`: suppress the message via `block`, or rewrite
376  the user-visible text via `replace_text`. The model's original
377  text stays in the transcript so the model sees what it actually
378  said on subsequent turns.
379
380#### `event_intercept_response`
381
382Reply to an `event_intercept` from the host. All fields default to
383"allow, pass through unmodified".
384
385| field | meaning |
386|---|---|
387| `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. |
388| `reason` | refusal text (on block) or pass-through note. |
389| `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. Rewrites replace the complete argument object; for `bash`, preserve the required positive integer `timeout` when changing `command`. |
390| `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. |
391| `context` | for `turn_start`: bounded hidden context sent only in the upcoming provider request. It is not a user message and is not persisted in the transcript. |
392
393Missing the response within 5s is treated as "allow" (i.e. an
394unresponsive extension never stalls the agent). When multiple
395extensions subscribe to the same event, they're consulted serially;
396the first `block` wins and rewrites (args / text) chain: each
397subsequent interceptor sees the previous one's output.
398
399```json
400{"type":"event_intercept_response","id":"...",
401 "block":true,"reason":"refused: matches danger pattern \"rm -rf\""}
402
403{"type":"event_intercept_response","id":"...",
404 "modified_args":{"command":"echo GUARDED: ls","timeout":30}}
405
406{"type":"event_intercept_response","id":"...",
407 "replace_text":"[redacted]"}
408
409{"type":"event_intercept_response","id":"...",
410 "context":"Current phase: parse files\nRemaining: validate inputs"}
411```
412
413#### `command_response` (reply to `command_invoked`)
414
415```json
416{"type":"command_response","id":"...","action":"prompt",
417 "prompt":"Show today's weather for Berlin in one line."}
418```
419
420`action` is one of:
421
422- `"prompt"` — submits `prompt` as a fresh user message; the agent
423  runs a turn against it.
424- `"insert"` — inserts `insert` into the editor at the cursor without
425  submitting.
426- `"display"` — appends `display` to the chat as a one-shot styled
427  note. No model call, nothing written to the transcript.
428- `"open_panel"` — opens an extension-owned interactive panel inside
429  zut. The panel content lives in `open_panel`.
430- `"noop"` — the extension handled it itself (e.g. it pushed
431  `notify` frames or kicked off background work). zut doesn't change
432  the UI in response.
433
434Example:
435
436```json
437{"type":"command_response","id":"...","action":"open_panel",
438 "open_panel":{
439   "id":"todos-main",
440   "title":"Todos",
441   "lines":["□ ship panel api","✓ persist state"],
442   "footer":"↑/↓ navigate - a add - x complete - esc close"
443 }}
444```
445
446If `error` is non-empty, zut renders it as a red status line
447regardless of `action`.
448
449#### `submit` (one-way, any time)
450
451Submits text as a user prompt in the interactive host. If the agent is
452idle, zut starts a turn immediately. If a turn is already running, zut
453queues the prompt behind it using the same queue path as typed input.
454Empty or whitespace-only text is ignored.
455
456```json
457{"type":"submit","text":"Summarize the selected workspace."}
458```
459
460In print / JSON / RPC modes this frame is ignored because there is no
461interactive editor or prompt queue.
462
463#### `panel_render` (one-way, while a panel is open)
464
465Pushes a fresh frame for an already-open panel.
466
467```json
468{"type":"panel_render","panel_id":"todos-main",
469 "title":"Todos",
470 "lines":["□ ship panel api","✓ persist state"],
471 "footer":"↑/↓ navigate - a add - x complete - esc close"}
472```
473
474#### `panel_close`
475
476Closes a previously-open panel.
477
478```json
479{"type":"panel_close","panel_id":"todos-main"}
480```
481
482#### `status` (one-way, persistent)
483
484Sets or replaces one status item owned by the extension. Sending an empty
485`text` clears the item.
486
487```json
488{"type":"status","key":"progress","level":"success",
489 "text":"2/4 tasks checked"}
490```
491
492#### `widget` (one-way, persistent)
493
494Sets or replaces a compact widget. Interactive zut renders widgets in the
495persistent chrome above the editor. Use `"above_input"` as the `position`;
496empty and unknown positions resolve to the same placement. Use `open_panel`
497for interaction or navigation.
498
499```json
500{"type":"widget","id":"progress","position":"above_input",
501 "title":"Progress","lines":["Indexing files","12/20 complete"]}
502```
503
504#### `widget_clear` (one-way)
505
506```json
507{"type":"widget_clear","id":"plan"}
508```
509
510#### `notify` (one-way, any time)
511
512```json
513{"type":"notify","level":"info",
514 "message":"refreshed cache (12 entries)"}
515```
516
517`level` is one of `info`, `success`, `warn`, `error`. The note shows
518up below the transcript with the extension's name in brackets. Notes
519are one-shot: they clear automatically when the user sends their next
520prompt (and on `esc` / `/clear`).
521
522#### `alert` (one-way, any time)
523
524Requests a host-owned structured alert. The first supported kind is
525`bell`, which asks the interactive terminal to emit one terminal bell.
526`reason` is semantic metadata and is not rendered as terminal text.
527The shared terminal-alert setting in `/settings` applies to extension
528alerts too; disabled or non-interactive hosts may ignore the request.
529
530```json
531{"type":"alert","kind":"bell","reason":"question_ready"}
532```
533
534The Go SDK sends the same frame with:
535
536```go
537e.Alert(ext.AlertRequest{Kind: ext.AlertKindBell, Reason: "question_ready"})
538```
539
540Extensions must not write BEL, ANSI, or other terminal bytes to stdout;
541stdout is reserved for protocol frames. In `--mode rpc`, this becomes an
542`ext_alert` JSON event for the RPC client to interpret.
543
544#### `clear_notes` (one-way, any time)
545
546Removes every note this extension previously pushed via `notify` /
547`display`. Use it for transient status lines (e.g. an approval prompt)
548so they do not stack up; notes from other extensions are untouched.
549
550```json
551{"type":"clear_notes"}
552```
553
554In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
555event (alongside `ext_notify` / `ext_display`).
556
557#### `shutdown_ack`
558
559Sent in response to `shutdown`. Extension should exit promptly after.
560
561### Host → extension
562
563#### `hello_ack`
564
565```json
566{"type":"hello_ack","protocol_version":2,
567 "zut_version":"0.0.7","provider":"anthropic",
568 "model":"claude-opus-4-7","cwd":"/path/to/zut",
569 "extension_dir":"/path/to/zut/.zut/extensions/todos",
570 "data_dir":"/path/to/zut/.zut/extensions/todos",
571 "session":{"id":"branch-1","parent_id":"root-1",
572             "path":".../session.jsonl","cwd":"/path/to/zut",
573             "fork_point":4}}
574```
575
576Sent immediately after a matching `hello`. The protocol version is a
577major and must match the version sent by the extension. Wait for this frame
578before sending registrations if they depend on host metadata. The extension can use
579these fields to decide which commands to register (e.g. only register
580a Python tool on macOS, only register a model-specific shortcut for
581opus, etc.). `cwd` is the user's project directory; the extension
582process itself runs from the extension directory, so do not use
583`os.Getwd()` or `process.cwd()` when you need the project path.
584`extension_dir` / `data_dir` are where the extension should persist
585its own state (for example `todos.json`, cached metadata, or auth
586tokens scoped to that extension).
587
588#### `command_invoked`
589
590```json
591{"type":"command_invoked","id":"...",
592 "name":"weather","args":"berlin"}
593```
594
595`args` is everything the user typed after the command name, trimmed.
596
597#### `tool_call`
598
599Sent when the LLM invokes a tool the extension registered. `args` is
600the parsed JSON object the model produced; the extension is
601responsible for validating/coercing it.
602
603```json
604{"type":"tool_call","id":"...","name":"weather",
605 "args":{"city":"Berlin"}}
606```
607
608Reply with `tool_result` within the host's tool timeout (default 60s).
609Missing the timeout surfaces an error to the model and the call is
610marked as failed. Tools registered with `"interactive": true` have no reply
611timeout (see *Interactive tools* above).
612
613The request write itself is always bounded, independently of the reply
614deadline: host frames are queued in order, synchronous writes have a
615five-second timeout, and a write timeout disconnects the extension. The queue is
616bounded to 256 frames and 16 MiB; overflowing it disconnects the extension
617rather than dropping frames silently. If the agent cancels a call
618while its `tool_call` frame is still queued, the frame is withdrawn and the
619transport stays healthy. If writing has begun, the host allows up to 250 ms
620for that frame to finish, then disconnects if it is still blocked: a torn frame
621cannot be withdrawn, and other pending calls on that extension fail too.
622
623Every `tool_call` carries a correlation `id` that is unique within the host
624process. A `tool_result` for an id the host no longer waits on (timed out,
625cancelled, or already answered) is ignored.
626
627#### `tool_cancel`
628
629Sent best-effort when a delivered `tool_call` is abandoned because the agent
630context was cancelled or the reply timeout elapsed, and only to extensions
631that advertised `"tool_cancel"` in `hello.capabilities`. Normal completion
632never sends it. `id` is the `tool_call` correlation id, not a transcript
633tool id.
634
635```json
636{"type":"tool_cancel","id":"..."}
637```
638
639Stop the associated work, close any panel it opened, and discard its pending
640state. Ignore unknown or already-completed ids. `tool_cancel` may arrive
641before your handler goroutine has started, so register cancellation state
642before dispatching work. Delivery is not guaranteed after a transport
643disconnect; extensions must also clean up on `shutdown` and on stdin EOF.
644The host ignores late results for abandoned calls, including races with
645cancellation.
646
647#### `event`
648
649Lifecycle notification for events the extension subscribed to via
650`subscribe`. One-way — no response expected.
651
652```json
653{"type":"event","event":"session_opened",
654 "session":{"id":"branch-1","parent_id":"root-1","fork_point":4},
655 "state":{"version":1}}
656{"type":"event","event":"turn_start","step":1}
657{"type":"event","event":"tool_call",
658 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
659{"type":"event","event":"tool_confirmation_requested",
660 "tool_id":"...","tool_name":"read","tool_preview":"foo.go"}
661{"type":"event","event":"turn_end","stop":"end_turn"}
662```
663
664#### `event_intercept`
665
666Sent when zut wants to give the extension a chance to block, modify,
667or annotate a lifecycle event before it happens. Reply with
668`event_intercept_response` within 5s; missing the deadline is
669treated as "allow".
670
671Payload fields depend on the event:
672
673```json
674// tool_call: includes the tool id, name, and parsed args
675{"type":"event_intercept","id":"...","event":"tool_call",
676 "tool_id":"...","tool_name":"bash",
677 "tool_args":{"command":"rm -rf /tmp/foo","timeout":30}}
678
679// turn_start: includes the step number
680{"type":"event_intercept","id":"...","event":"turn_start",
681 "step":3}
682
683// assistant_message: includes the assembled text
684{"type":"event_intercept","id":"...","event":"assistant_message",
685 "text":"here is your api key: sk-ant-..."}
686```
687
688#### `panel_key`
689
690Sent while an extension-owned panel is focused. `key` is a normalized
691name (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `pageup`,
692`pagedown`, `home`, `end`, `backspace`, `delete`, `rune`). For
693`key:"rune"`, `text` carries the typed character.
694
695```json
696{"type":"panel_key","panel_id":"todos-main","key":"down"}
697{"type":"panel_key","panel_id":"todos-main","key":"rune","text":"x"}
698```
699
700#### `panel_close`
701
702Sent when the user closes the focused panel from zut (for example with
703Esc or Ctrl+C). The extension should treat this as the panel lifetime
704ending and stop sending `panel_render` updates for that `panel_id`.
705
706```json
707{"type":"panel_close","panel_id":"todos-main"}
708```
709
710#### `shutdown`
711
712Sent during graceful zut exit (or `/reload-ext` once that lands).
713Reply with `shutdown_ack` and then exit.
714
715## Managing extensions from the CLI
716
717```
718zut ext list                              list installed extensions and their state
719zut ext doctor                            diagnose load, registration, and conflict issues
720zut ext install <path|git-url>             copy / clone into $ZUT_HOME/extensions/
721zut ext install --build=go <local-path>   build local Go source, then install
722zut ext remove <name>                     delete an extension directory
723zut ext enable <name>           re-enable a disabled extension
724zut ext disable <name>          disable without removing
725zut ext logs <name> [-f]        cat / tail the extension's stderr
726```
727
728`zut ext doctor` runs the same discovery path as zut startup, but reports
729what happened instead of changing the fail-soft runtime behavior. It shows
730manifest errors, disabled or shadowed extensions, subprocess load errors,
731ready/auto-ready status, registered commands/tools, registration conflicts,
732warnings, and each extension's stderr log path.
733
734`zut ext install <path>` does a recursive copy; `<git-url>` does a
735shallow clone. Both validate that the destination contains an
736`extension.json` and roll back if not. No build runs unless the local install
737is invoked with the explicit `--build=go` option. That option currently supports
738only local Go source paths; clone remote source locally before building it.
739
740## Loading an extension for one run
741
742For iteration on a working copy, skip the install + reload cycle
743and load straight from disk for one zut session:
744
745```
746zut --ext ./my-extension        # short form: -e ./my-extension
747zut --ext ./a -e ./b            # repeatable
748```
749
750`--ext` paths take precedence over installed extensions of the same
751name, so you can shadow an installed copy with a work-in-progress
752version without uninstalling first. Nothing is copied or persisted;
753the extension dies with zut like any other subprocess.
754
755## SDKs
756
757Writing the wire protocol by hand is fine for one-off scripts, but
758for anything bigger the SDKs handle the boilerplate.
759
760### Go — `packages/agent/ext`
761
762```go
763package main
764
765import (
766    "encoding/json"
767    "github.com/bnema/zut/packages/agent/ext"
768)
769
770func main() {
771    e := ext.New("hello", "1.0.0")
772
773    // Slash command
774    e.Command("hello", "say hi", func(args string) ext.Response {
775        return ext.Prompt("Greet me in one short sentence.")
776    })
777
778    // LLM-callable tool
779    e.Tool("weather", "Current weather for a city.",
780        json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
781        func(args json.RawMessage) ext.ToolResult {
782            var in struct{ City string `json:"city"` }
783            json.Unmarshal(args, &in)
784            return ext.TextResult(in.City + ": sunny")
785        })
786
787    // Optional: register project-specific commands after hello_ack.
788    e.OnHello(func(host ext.HostInfo) {
789        if host.CWD != "" {
790            e.Command("cwd", "show the current project directory", func(args string) ext.Response {
791                return ext.Display(host.CWD)
792            })
793        }
794    })
795
796    e.Run()
797}
798```
799
800Build with `go build -o hello .`, drop the binary + an `extension.json`
801into `$ZUT_HOME/extensions/hello/`.
802
803`OnHello` is optional. Use it when configuration or registrations need
804host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZutVersion`,
805`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
806`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
807
808Session-aware extensions can subscribe to `session_opened`,
809`session_switched`, `session_forked`, and `session_compacted`; `Event.Session`
810identifies the active branch and `Event.State` contains that extension's
811latest persisted snapshot. A tool can return opaque state with
812`result.Details = ext.JSONDetails(value)`.
813
814`TurnStartDecision.Context` supplies hidden bounded context for the next model
815request. `SetStatus`, `SetWidget`, and their clear methods update persistent
816interactive chrome without entering the transcript.
817
818For Go extensions, use the SDK placement constant:
819
820```go
821e.SetWidget("progress", ext.WidgetPositionAboveInput, "Progress", lines)
822e.ClearWidget("progress")
823```
824
825The SDK has four interceptor hooks, all optional:
826
827```go
828// e is the *ext.Extension returned by ext.New(...).
829
830// Refuse calls or rewrite args before they run.
831e.InterceptToolCall(func(tool string, args json.RawMessage) (bool, string) {
832    if tool == "bash" { /* inspect args, return false, reason */ }
833    return true, ""
834})
835
836// Richer variant: returns ToolCallDecision so you can also rewrite
837// args via ModifiedArgs.
838e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision {
839    return ext.ToolCallDecision{
840        ModifiedArgs: json.RawMessage(`{"command":"echo GUARDED","timeout":30}`),
841    }
842})
843
844// Block the next turn before the model is called.
845e.InterceptTurnStart(func(step int) ext.TurnStartDecision {
846    if time.Now().Hour() < 9 { return ext.TurnStartDecision{Block: true, Reason: "outside business hours"} }
847    return ext.TurnStartDecision{}
848})
849
850// Scrub or rewrite the assistant's final text before the user sees it.
851e.InterceptAssistantMessage(func(text string) ext.AssistantMessageDecision {
852    return ext.AssistantMessageDecision{
853        ReplaceText: strings.ReplaceAll(text, "SECRET", "[redacted]"),
854    }
855})
856```
857
858See:
859- `examples/extensions/hello/` — slash commands
860- `examples/extensions/clock/` — slash commands in plain Node, no SDK
861- `examples/extensions/weather/` — LLM-callable tool
862- `examples/extensions/guard/` — event subscriptions + tool-call
863  interception (refuses dangerous bash patterns)
864- `examples/extensions/todo/` — interactive persistent panel + tool
865- `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
866
867### Hot reload
868
869Type `/reload-ext` in the TUI to tear down every running extension
870subprocess, re-read the manifests from disk, and respawn the set.
871The agent's tool registry is rebuilt automatically, so freshly-
872registered extension tools become callable without restarting zut.
873Useful while developing an extension: edit, save, `/reload-ext`,
874done. Explicit `--ext` paths are remembered and reloaded alongside
875discovered extensions. The temporary reload status reports each load
876error in red, including its message, and dismisses itself after five
877seconds. Extension failures from startup and `/reload-ext` remain in the
878scrolling chat until a successful reload or `/clear`, without being sent to
879the model or saved in the session transcript. Subprocess startup and
880handshake errors identify the extension and its directory, configured executable,
881and declared language (if present), along with the original error and stderr log
882path when the log was opened. Manifest errors also identify the extension
883directory. Multiline diagnostics preserve their line breaks in the `/reload-ext`
884status and scrolling chat, and in `zut ext doctor` output.
885If a subprocess exits before sending `hello`, the error also reports its exit
886status or terminating signal.
887
888### TypeScript / Python
889
890These SDKs aren't in the main repo yet; the wire format is small
891enough that a `~30 line` raw script gets you started in either
892language. See the [Quick start](#quick-start) Python example for the
893shape. SDK packages will land in follow-up commits.
894
895## Security
896
897Extensions run with **the user's full filesystem and network
898permissions**. Treat installing an extension the same as installing
899any other binary on your machine.
900
901`zut ext install <git-url>` clones from any URL you give it. There's
902no sandbox in v1; if you need isolation, install only extensions you
903trust or run zut under your platform's sandboxing tool (`bwrap` /
904`sandbox-exec` / AppContainer).
905
906## Roadmap
907
908Phase 1 (shipped):
909- [x] subprocess lifecycle + hello handshake
910- [x] `register_command` + `command_invoked`
911- [x] `notify` + `clear_notes` + structured terminal alerts
912- [x] `zut ext` CLI
913
914Phase 2 (shipped):
915- [x] `register_tool` + `tool_call` + `tool_result`
916- [x] `ready` sentinel for safe agent-registry build timing
917- [x] tool result attribution surfaces extension name in details
918
919Phase 3 (shipped):
920- [x] event subscriptions (`session_start`, `session_opened`,
921      `session_switched`, `session_forked`, `session_compacted`, `turn_start`,
922      `turn_end`, `tool_call`, `assistant_message`)
923- [x] tool-call interception (block before execution)
924
925Phase 4 (shipped):
926- [x] interception for `turn_start` and `assistant_message` (in
927      addition to `tool_call`)
928- [x] modify tool args mid-flight via `modified_args`
929- [x] rewrite user-visible assistant text via `replace_text`
930- [x] `/reload-ext` slash command (hot-reload without restarting zut)
931
932Phase 5 (shipped):
933- [x] session/branch identity in `hello_ack` and lifecycle events
934- [x] session open/switch/fork/compaction notifications
935- [x] opaque extension state persisted with session branches
936
937Phase 6 (shipped):
938- [x] hidden bounded context returned from `turn_start` interception
939- [x] persistent extension status and widget frames
940- [x] manifest-declared bundled skill discovery with safe path validation
941
942Future (no firm timeline):
943- [ ] TypeScript and Python SDK packages (currently the wire format
944      is stable enough to hand-roll, see the Python quick-start)
945- [ ] HTTP / WebSocket transport variants (today: subprocess stdio)
946- [ ] 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