Extensions can hook into 31 lifecycle events:
| Event | Description |
|---|---|
OnSessionStart |
Session initialized |
OnSessionShutdown |
Session ending |
OnBeforeAgentStart |
Before the agent loop begins |
OnAgentStart |
Agent loop started |
OnAgentEnd |
Agent loop completed (carries per-turn aggregates: tool counts, token deltas, cost, duration) |
OnLLMUsage |
Per-LLM-call token + cost delta (fires once per provider round-trip) |
OnToolCall |
Tool call requested by the model |
OnToolCallInputStart |
LLM began generating tool call arguments (tool name known, args streaming) |
OnToolCallInputDelta |
Streamed JSON fragment of tool call arguments |
OnToolCallInputEnd |
Tool argument streaming complete, before execution begins |
OnToolExecutionStart |
Tool execution beginning |
OnToolOutput |
Streaming tool output chunk (for long-running tools) |
OnToolExecutionEnd |
Tool execution completed |
OnToolResult |
Tool result returned |
OnInput |
User input received |
OnMessageStart |
Assistant message started |
OnMessageUpdate |
Streaming text chunk received |
OnMessageEnd |
Assistant message completed |
OnModelChange |
Model switched |
OnThinkingLevelChange |
Extended-thinking effort level changed |
OnTerminalResize |
Terminal resized (also fires once at startup) |
OnTurnStateChange |
UI entered or left the working state |
OnMCPToolsReady |
Background loading of the MCP servers finished (once) |
OnContextPrepare |
Context being assembled for the model |
OnBeforeFork |
Before forking a conversation branch |
OnBeforeSessionSwitch |
Before switching sessions |
OnBeforeCompact |
Before conversation compaction |
OnCustomEvent |
Custom inter-extension event received |
OnSubagentStart |
Subagent spawned by the main agent |
OnSubagentChunk |
Real-time output from subagent (text, tool calls, results) |
OnSubagentEnd |
Subagent completed with final response/error |
api.OnToolCall(func(event ext.ToolCallEvent, ctx ext.Context) {
ctx.PrintInfo("Calling tool: " + event.Name)
})
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// Per-turn aggregates populated by Kit's runtime — no parallel
// bookkeeping required in the handler.
ctx.PrintInfo(fmt.Sprintf(
"Turn finished: %d tool calls (%v), %d LLM round-trips, $%.4f, %dms",
e.ToolCallCount, e.ToolNames, e.LLMCallCount, e.CostDelta, e.DurationMs,
))
})
// Per-LLM-call usage — fires multiple times per turn (once per round-trip).
// Use for accurate budget enforcement between calls.
api.OnLLMUsage(func(e ext.LLMUsageEvent, ctx ext.Context) {
ctx.PrintInfo(fmt.Sprintf(
"%s/%s step=%d tokens=↑%d ↓%d cost=$%.4f (%s)",
e.Provider, e.Model, e.StepNumber,
e.InputTokens, e.OutputTokens, e.Cost, e.FinishReason,
))
})
AgentEndEvent fields (in addition to Response and StopReason):
| Field | Type | Description |
|---|---|---|
ToolCallCount |
int |
Total tool invocations during the turn |
ToolNames |
[]string |
Tool names in call order (duplicates preserved) |
LLMCallCount |
int |
LLM round-trips / tool-loop iterations |
InputTokensDelta |
int |
Sum of input tokens across all LLM calls this turn |
OutputTokensDelta |
int |
Sum of output tokens across all LLM calls this turn |
CacheReadTokensDelta |
int |
Sum of cache-read tokens this turn |
CacheWriteTokensDelta |
int |
Sum of cache-write tokens this turn |
CostDelta |
float64 |
Cost in USD (zero when pricing is unknown or OAuth credentials) |
DurationMs |
int64 |
Wall-clock time from AgentStart to AgentEnd |
LLMUsageEvent fields:
| Field | Type | Description |
|---|---|---|
InputTokens / OutputTokens |
int |
Per-call token deltas |
CacheReadTokens / CacheWriteTokens |
int |
Per-call cache token deltas |
Cost |
float64 |
Per-call USD cost (zero when pricing unknown) |
Model / Provider |
string |
Model used for this specific call — may differ from earlier calls if ctx.SetModel was called mid-turn |
StepNumber |
int |
Zero-based step index within the turn |
FinishReason |
string |
Provider finish reason for this call ("stop", "tool_calls", "length", …) |
RequestID |
string |
Optional provider correlation id (may be empty) |
Register custom tools that the LLM can invoke:
api.RegisterTool(ext.ToolDef{
Name: "weather",
Description: "Get current weather for a location",
Parameters: map[string]ext.ParameterDef{
"city": {Type: "string", Description: "City name", Required: true},
},
Handler: func(ctx ext.Context, params map[string]any) (string, error) {
city := params["city"].(string)
return "Sunny, 72°F in " + city, nil
},
})
Register slash commands that users can invoke directly:
api.RegisterCommand(ext.CommandDef{
Name: "stats",
Description: "Show context statistics",
Handler: func(ctx ext.Context, args string) {
stats := ctx.GetContextStats()
ctx.PrintInfo(fmt.Sprintf("Tokens: %d", stats.TotalTokens))
},
})
Add persistent status displays above or below the input area:
ctx.SetWidget(ext.WidgetConfig{
ID: "token-count",
Placement: ext.WidgetBelow,
Content: ext.WidgetContent{Text: "Tokens: 1,234"},
})
// Update later — same ID replaces the previous widget
ctx.SetWidget(ext.WidgetConfig{
ID: "token-count",
Placement: ext.WidgetBelow,
Content: ext.WidgetContent{Text: "Tokens: 2,456"},
})
// Remove
ctx.RemoveWidget("token-count")
Placement is ext.WidgetAbove or ext.WidgetBelow. Priority orders
multiple widgets within the same slot (lower renders first).
Set Markdown: true to render Text as styled markdown — headings, bold,
inline code and lists are formatted and sized to the widget’s content column:
ctx.SetWidget(ext.WidgetConfig{
ID: "notes",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Markdown: true,
Text: "## Build\n\n**passing** — `go test ./...`",
},
})
Render callbacks follow the shared TUI callback safety rules.
Text covers static content. For anything Kit has no vocabulary for — sparklines,
gauges, box drawing, sprites — supply a Render function instead. It receives the
width in columns available for content (the gutter and padding are already
subtracted) and Kit uses the returned string verbatim:
ctx.SetWidget(ext.WidgetConfig{
ID: "cpu-gauge",
Placement: ext.WidgetAbove,
Style: ext.WidgetStyle{NoBorder: true},
Content: ext.WidgetContent{
Render: func(width int) string {
filled := int(load * float64(width))
return "\033[38;5;82m" + strings.Repeat("━", filled) +
"\033[0m" + strings.Repeat("─", width-filled)
},
},
})
Render takes priority over Text, and Markdown is ignored when it is set —
a render function is expected to do its own styling. Returning an empty string
hides the widget. A panic inside Render is contained: the widget is hidden and
the error logged, rather than taking down the TUI.
The example above hardcodes its color. To follow whatever theme the user has
active instead, read ctx.GetTheme() inside
Render and paint with ANSI / ANSIBold.
Render also works on headers and footers via HeaderFooterConfig.
Use FillHeight for a panel that replaces the transcript area without guessing
how many rows the other UI elements need:
Content: ext.WidgetContent{
FillHeight: true,
RenderSized: func(width, height int) string {
return renderPanel(width, height)
},
},
This applies only to WidgetAbove and WidgetBelow. Headers, footers and
overlays ignore these fields. Kit measures the other widgets, header, footer,
activity and subagent rows, queued messages, confirmation row and composer first.
Fill widgets share the remaining rows evenly. Ordering is above before below,
then each slot’s priority order; earlier widgets receive any remainder rows.
The transcript has zero rows until all fill widgets are removed.
RenderSized takes priority over Render only in fill mode. It receives the
content width (without the gutter and padding) and the allocated height. Kit
pads or truncates the output to exactly that height, including empty output.
At zero height the callback is not called. Without RenderSized, fill mode
uses Render or Text and enforces the same height limit. Remove the widget
to restore the transcript; returning an empty string does not release its rows.
Height-only changes invalidate the render cache, including animated widgets.
Like Render, this callback must be cheap and must not block. In interpreted
extensions, use an anonymous closure when calling a helper declared later in
the file. See file-explorer.go for a complete example.
Kit’s animation clock is demand-driven — it runs while the startup logo or the activity spinner needs it and stops otherwise, so an idle session costs nothing. A widget that only reads state repaints whenever something else causes a render, which when idle means roughly twice a second (the input cursor blink). That is fine for a counter and visibly choppy for a spinner.
Set RefreshHz to hold the clock open and repaint at a chosen rate:
Content: ext.WidgetContent{
RefreshHz: 15,
Render: func(width int) string { return spinnerFrame() + " working" },
},
RefreshHz |
Behaviour | Use for |
|---|---|---|
0 (default) |
Static. Repaints only when something else renders. | Counters, status text |
4–8 |
Gentle pulse | Slow progress, breathing indicators |
10–15 |
Smooth | Spinners, meters |
30 |
Kit’s ceiling | Continuous motion |
This is a real cost: a non-zero value means the app never idles. Ask for the
lowest rate that looks right. Kit calls Render at approximately the requested
rate rather than on every frame, so a 5Hz widget does not pay 30Hz of
interpreter crossings.
Because
Renderruns on every frame it must be cheap and must not block. No network calls, no locks held across the call. Compute in an event handler or goroutine, store the result, and format it here.
See arbitrary-ui.go
for a live dashboard and bad-apple.go
for 30fps playback.
Persistent content above and below the conversation:
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Project: my-app | Branch: master"},
})
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "Plan Mode (read-only)"},
})
Headers and footers accept the same WidgetContent as widgets, so Markdown,
Render and RefreshHz all apply.
Plain Text is rendered at full terminal width with no truncation — a longer
line wraps and silently consumes a row of scrollback. Measure against
ctx.GetTerminalSize() and truncate before calling SetHeader/SetFooter, or
use Render, which is handed the exact width to draw into.
width, height := ctx.GetTerminalSize() // 0, 0 outside the interactive TUI
api.OnTerminalResize(func(e ext.TerminalResizeEvent, ctx ext.Context) {
// e.Width, e.Height — re-render chrome at the new size
})
OnTerminalResize also fires once at startup, so a handler can lay out
immediately instead of waiting for the user to resize.
This is a function, not a field, so it reports the live size. A long-lived
goroutine (a ticking clock in a footer, say) that captured a Context still
observes resizes; a struct field would freeze at the value copied when the
handler was invoked.
Note that multi-byte characters occupy more than one column — count display
width, not bytes or runes, when fitting text to width.
Custom status bar entries:
ctx.SetStatus("mode", "Planning")
ctx.RemoveStatus("mode")
ctx.SetUIVisibility hides parts of the TUI that Kit draws itself. The zero
value shows everything:
| Field | Hides |
|---|---|
HideStartupMessage |
The startup block: the wordmark, the model line and the list of loaded items |
HideLogo |
Only the wordmark and its animation; the rest of the startup block stays |
HideStatusBar |
The status bar below the composer |
HideSeparator |
The count of queued and steering messages above the composer |
HideMCPStartupMessages |
The message for each MCP server that has loaded (MCP tools ready) |
Call it in OnSessionStart, so that the startup block is drawn with it. Each
call sets all fields, so a later call replaces an earlier one.
level := ctx.GetThinkingLevel() // exact model-specific effort, including future names
// off omits effort; none is a literal provider value
api.OnThinkingLevelChange(func(e ext.ThinkingLevelChangeEvent, ctx ext.Context) {
// e.NewLevel, e.PreviousLevel string
// e.Source string — "user" (/thinking or shift+tab) or "model_fallback"
})
Models without reasoning support report "off". To distinguish “reasoning is
switched off” from “this model cannot reason at all”, pair it with
ctx.GetModelCapabilities("").Reasoning.
Source is "model_fallback" when Kit downgrades the level automatically
because the newly selected model does not support the previous one.
api.OnTurnStateChange(func(e ext.TurnStateChangeEvent, ctx ext.Context) {
// e.State, e.Previous string — "working" or "idle"
})
This is a superset of OnAgentStart/OnAgentEnd: it also covers work that
never reaches the agent loop (shell commands run with !) and fires on every
path back to idle, including cancellation and error.
| Use | For |
|---|---|
OnTurnStateChange |
UI that tracks whether Kit is busy — a spinner, a turn timer |
OnAgentStart / OnAgentEnd |
Agent turns specifically, plus their token usage and cost |
Interactive TUI only — like OnTerminalResize, this does not fire in headless,
ACP, or script mode.
api.OnMCPToolsReady(func(e ext.MCPToolsReadyEvent, ctx ext.Context) {
// e.Servers []ext.MCPServerInfo, sorted by name
// s.Name string, s.Tools []string (sorted, without the "server__" prefix)
})
Fires once, when the background loading of the configured MCP servers has
finished. Servers lists the servers that loaded. A server that failed to load
is not listed. The event does not fire when no MCP servers are configured.
Kit prints a message for each server as it loads. To show one summary instead,
hide these messages in OnSessionStart and print the summary when the event
arrives. PrintBlock with Markdown: true renders a markdown table:
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
ctx.SetUIVisibility(ext.UIVisibility{HideMCPStartupMessages: true})
})
api.OnMCPToolsReady(func(e ext.MCPToolsReadyEvent, ctx ext.Context) {
table := "| MCP server | Tools |\n|---|---|\n"
for _, s := range e.Servers {
table += "| " + s.Name + " | " + strings.Join(s.Tools, ", ") + " |\n"
}
ctx.PrintBlock(ext.PrintBlockOpts{Text: table, Markdown: true})
})
HideMCPStartupMessages hides only the messages for servers that loaded.
examples/extensions/mcp-table.go is a complete extension with an option to
choose between the table, no messages, and the default messages.
Interactive TUI only, like OnTerminalResize.
Global keyboard shortcuts:
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+t",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handle shortcut
})
Handlers run in a goroutine, so they may call blocking APIs like
ctx.PromptSelect without stalling the TUI.
Run /shortcuts in the TUI to list every registered binding, grouped by the
extension file that registered it.
Keys are normalized at registration, so modifier order and casing do not
matter — "Ctrl+Shift+S", "control+shift+s" and "shift+ctrl+s" all resolve
to the same binding. control, option/opt, cmd/command and win are
accepted as aliases for ctrl, alt, meta and super.
A shifted key can be written either way: "shift+a" and "A" both match the
same press, as do "shift+/" and "?". Common key-name spellings are folded
too (escape → esc, return → enter, pgdn/pagedown → pgdown).
A bare single character keeps its case, because "A" and "a" are genuinely
different presses.
Kit dispatches extension shortcuts early — before its own scrollback, selector and chord bindings — so a shortcut can claim almost any key. Two cases are handled specially:
| Key | Behaviour |
|---|---|
ctrl+c |
Rejected. Kit consumes it for cancel/quit before extensions are consulted, so the handler could never fire. The registration is dropped with a logged warning. |
esc, ctrl+x, pgup, pgdown, ctrl+home, ctrl+end, shift+tab, enter, tab, up, down |
Accepted with a warning. The shortcut wins, shadowing Kit’s built-in behaviour. |
Ctrl+Alt+A opens the built-in subagent run picker before extension shortcuts are consulted while composing a prompt or while the parent agent works, when no modal or leader chord is active. Registration does not reject this binding, but an extension handler cannot override the picker in those states. Use a different key for extension shortcuts.
An armed Ctrl+X leader chord takes precedence over shortcuts, so binding a
chord suffix such as "s" does not break Ctrl+X s.
Prefer modifier combinations. A bare character like
"s"fires on every press of that key outside a modal — including while you are typing a slash command.
Shortcuts do not fire while a modal prompt or overlay is open, or during message navigation.
Modal dialogs with markdown content:
ctx.ShowOverlay(ext.OverlayConfig{
Title: "Help",
Content: "# Keyboard Shortcuts\n\n- **ctrl+t** — Toggle plan mode\n- **ctrl+s** — Save session",
})
Render callbacks follow the shared TUI callback safety rules.
Customize how specific tool calls are displayed in the TUI. RenderHeader
replaces the parameter summary on the header line; RenderBody replaces the
result body. Both receive the width they may draw into, and returning an empty
string falls back to Kit’s default rendering:
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell",
RenderHeader: func(toolArgs string, width int) string {
return "$ " + toolArgs
},
RenderBody: func(toolResult string, isError bool, width int) string {
return toolResult
},
})
Set BorderColor and/or Background (hex strings) to give the tool block its
own stripe and backdrop. Tool blocks are otherwise unattributed, so the stripe
appears only when asked for — it marks a tool as special rather than restyling
every call:
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "deploy",
BorderColor: "#c678dd",
Background: "#1b1b2b",
RenderBody: func(result string, isError bool, width int) string {
return result
},
})
Set BodyMarkdown: true to pass RenderBody’s output through the markdown
renderer.
Set MaxBodyLines to shorten a result that Kit renders itself, with its
builtin renderer or as plain text. It applies when RenderBody is nil or
returns an empty string. The message inspector still shows the full result:
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "read",
MaxBodyLines: 6,
})
ToolName can also be a pattern in the syntax of Go’s path.Match, so that
one renderer applies to a group of tools. * matches every tool, github__*
the tools of the MCP server github, and *__* every MCP tool. The pattern *
alone also matches a name with a /. In a longer pattern, as in path.Match,
* and ? do not match a /. A renderer that names the tool wins over a
pattern. When several patterns match, the
last one registered wins, so register the general pattern first:
api.RegisterToolRenderer(ext.ToolRenderConfig{ToolName: "*", MaxBodyLines: 6})
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "*__*",
RenderBody: func(result string, isError bool, width int) string {
if isError {
return "" // fall back to Kit's rendering
}
return "(result hidden)"
},
})
Register renderers in Init. A renderer registered later, for example in an
event handler, has no effect.
Key and render callbacks must return promptly. Print, PrintInfo, PrintError,
PrintBlock, SetEditorText, the TUI notification from SetModel, and Exit
use deferred FIFO delivery. These notifications do not need your own goroutine.
Delivery is a request, not an acknowledgement that the UI has processed it.
SetModel still resolves the model and runs hooks synchronously.
NewSession, PromptSelect, PromptMultiSelect, PromptConfirm, PromptInput,
and ShowOverlay wait for replies. Never call them synchronously from
HandleKey, editor/widget/message/tool render callbacks, or other TUI-loop
callbacks. Run them outside the callback and handle errors, for example:
go func() {
if err := ctx.NewSession(""); err != nil {
ctx.PrintError(err.Error())
}
}()
Calling SetEditorText from HandleKey requests a later editor update; it does
not change the callback’s currentText argument. Do not print or mutate UI state
unconditionally on every render: this can cause repeated repaints. FIFO order
covers these notifications only, not unrelated input or widget events. Pending
notifications can be discarded at shutdown. A producer that outpaces the UI can
grow the queue, so avoid unbounded output loops.
Render callbacks follow the shared TUI callback safety rules.
Named renderers invoked explicitly from extension code via
ctx.RenderMessage(name, content):
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "build-status",
Render: func(content string, width int) string {
return "▸ " + content
},
})
ctx.RenderMessage("build-status", "all tests passed")
Note: the returned string is not emitted verbatim. In interactive mode Kit re-wraps it to the content width and nests it inside a system message block (gutter glyph plus indent), so box drawing that assumes full terminal width is wrapped a second time. Size output to roughly
width-4and prefer inline styling over full-width frames. For output Kit uses as-is, use a widget with aRendercallback.
Key and render callbacks follow the shared TUI callback safety rules.
Handle key events and wrap the editor’s rendering:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
if key == "esc" {
return ext.EditorKeyAction{Type: ext.EditorKeyConsumed}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
Type is one of:
| Type | Effect |
|---|---|
ext.EditorKeyPassthrough |
Let the built-in editor handle the key normally. Any unrecognized Type behaves this way too, so a zero-value action passes through. |
ext.EditorKeyConsumed |
The extension handled it; the editor never sees the key. |
ext.EditorKeyRemap |
Replace the key with RemappedKey before the editor sees it. |
ext.EditorKeySubmit |
Submit immediately, using SubmitText (or the current text when empty). |
HandleKeyruns synchronously on the TUI event loop. Unlike a shortcut handler, a blocking call here freezes the whole interface. Keep it fast and hand slow work to a goroutine.
The interceptor is the last hook before the editor, so it never sees keys an
earlier stage already consumed: ctrl+c, any registered extension shortcut,
esc while a turn is running, ctrl+x and its chord suffix, and the
scrollback bindings such as pgup.
Select, multi-select, confirm, and text input dialogs. Each blocks the calling
goroutine until the user answers, and each returns a result struct whose
Cancelled field is true if the user pressed ESC or the prompt was unavailable
(non-interactive mode):
// Single select
res := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Choose a model",
Options: []string{"claude-sonnet", "gpt-4o", "llama3"},
})
if !res.Cancelled {
ctx.PrintInfo("picked " + res.Value) // also res.Index
}
// Multi-select — Space toggles, a selects all, n clears, Enter confirms
pick := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
Message: "Which checks should run?",
Options: []string{"vet", "test", "lint"},
DefaultSelected: []int{0, 1}, // nil selects everything
})
if !pick.Cancelled {
ctx.PrintInfo(strings.Join(pick.Values, ", ")) // also pick.Indices
}
// Confirm
yes := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Delete this file?",
})
// Text input
name := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter project name",
Placeholder: "my-project",
})
PromptMultiSelect returns both Values (the selected option text) and
Indices (their zero-based positions), so you can map back to your own data
without string matching.
Register configurable extension options:
api.RegisterOption(ext.OptionDef{
Name: "auto-commit",
Description: "Automatically commit on shutdown",
DefaultValue: "false",
})
Spawn in-process child Kit instances:
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: "Analyze the test files and summarize coverage",
Model: "anthropic/claude-haiku-latest",
SystemPrompt: "You are a test analysis expert.",
Blocking: true,
})
With Blocking: false (the default), the subagent runs in a background goroutine and SpawnSubagent returns immediately with a non-nil handle (handle.Wait(), handle.Done(), handle.Kill()); use OnComplete/OnEvent callbacks for results. See Subagents for a full background-mode example.
Subagent sessions are persisted and linked to the host session by default. Set SessionID to a previous run’s SubagentResult.SessionID to resume that subagent for follow-up prompts; see Session linking and resuming.
The native subagent inspector also shows retained runs started by extensions. Users can open it with /subagents without adding a monitoring widget. /subagents is reserved for the built-in inspector and cannot be overridden by an extension command. See command compatibility if your extension previously used that name. The snapshot methods are Go SDK APIs, not fields on the extension Context.
When the LLM uses the built-in subagent tool, extensions can monitor the subagent’s activity in real-time using three lifecycle events:
// Subagent started
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
// e.ToolCallID — unique ID for this subagent invocation
// e.Task — the task/prompt sent to the subagent
ctx.PrintInfo(fmt.Sprintf("Subagent started: %s", e.Task))
})
// Real-time streaming output from subagent
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
// e.ToolCallID — matches the start event
// e.Task — task description
// e.ChunkType — "text", "tool_call", "tool_execution_start", "tool_result"
// e.Content — text content (for text chunks)
// e.ToolName — tool name (for tool-related chunks)
// e.IsError — true if tool result is an error
switch e.ChunkType {
case "text":
// Streaming text output
case "tool_call":
// Subagent is calling a tool
case "tool_execution_start":
// Tool execution started
case "tool_result":
// Tool execution completed (check e.IsError)
}
})
// Subagent completed
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
// e.ToolCallID — matches start event
// e.Task — task description
// e.Response — final response from subagent
// e.ErrorMsg — error message if subagent failed
if e.ErrorMsg != "" {
ctx.PrintError(fmt.Sprintf("Subagent failed: %s", e.ErrorMsg))
} else {
ctx.PrintInfo(fmt.Sprintf("Subagent completed: %s", e.Response))
}
})
This enables building widgets that display real-time subagent activity.
Make direct model calls without going through the agent loop:
response := ctx.Complete(ext.CompleteRequest{
Prompt: "Summarize this in one sentence: " + content,
})
Register, switch, and read color themes at runtime:
// Register a custom theme
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
})
// Switch to it
ctx.SetTheme("neon")
// List all available themes
names := ctx.ListThemes()
ctx.GetTheme() reports the colors currently in effect, so a widget can follow
the user’s theme instead of hardcoding a palette. The light/dark variants are
already resolved for the terminal’s appearance and every slot is a "#rrggbb"
string:
theme := ctx.GetTheme()
theme.Name // "catppuccin" — "" when the derived default is in effect
theme.Dark // true when the dark variants were resolved
theme.Accent // "#89b4fa"
Widget Render output is used verbatim, so nothing styles it for you. Two
helpers turn a theme color into an escape sequence:
| Method | Description |
|---|---|
theme.ANSI(color, text) |
Truecolor foreground, auto-reset |
theme.ANSIBold(color, text) |
Same, with the bold attribute |
ctx.SetWidget(ext.WidgetConfig{
ID: "build-status",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Render: func(width int) string {
th := ctx.GetTheme() // read per frame, not cached
return th.ANSIBold(th.Accent, "Build") + " " +
th.ANSI(th.Success, "passing")
},
},
Style: ext.WidgetStyle{BorderColor: ctx.GetTheme().Accent},
})
An empty or malformed color returns the text unchanged, so a partially-defined theme degrades to plain output rather than leaking escape codes.
ThemeColors carries the same field names as ThemeColorConfig,
but as a single resolved string per slot rather than a light/dark pair — it is
the read counterpart to the type you register with.
Call
GetTheme()insideRender, not once at setup.Renderruns on every frame, so reading there means a/themeswitch repaints in the new colors automatically.WidgetStyle.BorderColor, by contrast, is captured when the widget is set: to keep a border in sync, callSetWidgetagain when the theme changes. There is no theme-change event, so pollGetTheme().Accentfrom a goroutine if that matters.
See Themes for the full theme file format, built-in themes, and color reference.
Inter-extension communication:
// Emit
ctx.EmitCustomEvent("my-extension:data-ready", payload)
// Listen
api.OnCustomEvent("my-extension:data-ready", func(data any, ctx ext.Context) {
// handle event
})
Last-write-wins key-value store, scoped to the current session and persisted to a sidecar file (<session>.ext-state.json) outside the conversation tree:
ctx.SetState("myext:budget-cap", "10.00")
if cap, ok := ctx.GetState("myext:budget-cap"); ok {
// ...
}
ctx.DeleteState("myext:budget-cap")
keys := ctx.ListState() // []string, unspecified order
Reads are O(1) (no branch walk), writes don’t grow the session JSONL, and the store is not duplicated when the conversation forks. State is invisible to the LLM and survives session resume.
| Need | Use | Why |
|---|---|---|
| Snapshot state (“current value of X”) | SetState / GetState |
O(1) reads, sidecar file, last-write-wins |
| Audit log / event history | AppendEntry / GetEntries |
Append-only, lives in conversation tree, fork-aware |
| One-shot per-turn signal | Enriched AgentEndEvent fields |
No persistence needed; runtime tracks it for you |
| Per-LLM-call observation | OnLLMUsage event |
Already attributed to model/provider/step |
Using AppendEntry for snapshot state has a cost: it’s O(branch_length) to read, fsyncs into the JSONL on every write, and the entry list duplicates on every fork. Prefer SetState for “what’s the current value of X?”-style data.
For ephemeral / in-memory sessions (no JSONL path) the state lives only in memory for the lifetime of the runner.
Extensions can access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.
Navigate the conversation tree, summarize branches, and implement “fresh context” loops:
// Get a specific node by ID with full metadata and children
node := ctx.GetTreeNode("entry-id")
// node.ID, node.ParentID, node.Type ("message"/"branch_summary"/etc)
// node.Role, node.Content, node.Model, node.Children ([]string)
// Get the current branch from root to leaf
branch := ctx.GetCurrentBranch() // []ext.TreeNode
// Get child entry IDs of a node
children := ctx.GetChildren("entry-id") // []string
// Navigate/fork to a different entry in the tree
result := ctx.NavigateTo("entry-id") // ext.TreeNavigationResult{Success, Error}
// Summarize a range of the branch using LLM
summary := ctx.SummarizeBranch("from-id", "to-id") // string
// Collapse a branch range into a summary entry (fresh context primitive)
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
Load and inject skills dynamically at runtime:
// Discover skills from standard locations
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.agents/skills/, ~/.config/kit/skills/,
// <project>/.agents/skills/, <project>/.kit/skills/
// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
// Spec fields: skill.Name, skill.Description, skill.License, skill.Compatibility,
// skill.Metadata, skill.AllowedTools, skill.DisableModelInvocation
// Plus content/path and Kit extensions: skill.Content, skill.Path, skill.Tags, skill.When
// Load all skills from a directory
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
// Inject a skill as context (pre-loads for next turn). The body is wrapped
// in a <skill_content name="..."> block, the same wrapper the /<name> slash
// command and the activate_skill tool use, so it survives /compact.
err := ctx.InjectSkillAsContext("skill-name") // error string
// Inject a skill file directly
err := ctx.InjectRawSkillAsContext("/path/to/skill.md") // error string
// Get all discovered skills
skills := ctx.GetAvailableSkills() // []ext.Skill
Parse and render templates with variable substitution:
// Parse a template to extract
tpl := ctx.ParseTemplate("name", "Hello , welcome to !")
// tpl.Name, tpl.Content, tpl.Variables ([]string)
// Render a template with variable values
vars := map[string]string{"name": "Alice", "place": "Kit"}
rendered := ctx.RenderTemplate(tpl, vars) // "Hello Alice, welcome to Kit!"
// Parse command-line style arguments
pattern := ext.ArgumentPattern{
Positional: []string{"command", "target"}, // $1, $2
Rest: "args", // $@
Flags: map[string]string{"--loop": "loop", "-f": "force"},
}
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
// result.Vars["command"] = "deploy"
// result.Vars["target"] = "staging"
// result.Flags["--loop"] = "5"
// Simple positional argument parsing ($1, $2, $@)
args := ctx.SimpleParseArguments("deploy staging --force", 2)
// args[0] = "deploy staging --force" (full input)
// args[1] = "deploy" ($1)
// args[2] = "staging" ($2)
// args[3] = "--force" ($@)
// Evaluate model conditionals with wildcards
matches := ctx.EvaluateModelConditional("claude-*") // bool
// Patterns: * matches any, ? matches single char, comma = OR
// Render content with <if-model> conditionals
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
rendered := ctx.RenderWithModelConditionals(content) // based on current model
Resolve model fallback chains and query capabilities:
// Resolve a chain of model preferences (tries each until available)
result := ctx.ResolveModelChain([]string{
"anthropic/claude-opus-4",
"anthropic/claude-sonnet-4",
"openai/gpt-4o",
})
// result.Model (selected), result.Capabilities, result.Attempted, result.Error
// Get capabilities for a specific model
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
// caps.Provider, caps.ModelID, caps.ContextLimit, caps.Reasoning, caps.Streaming
// caps.Pricing (see Model pricing below)
// Pass an empty string for the model currently in use
caps, err = ctx.GetModelCapabilities("")
// Check if a model is available (provider exists)
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4") // bool
// Get current provider/model ID
provider := ctx.GetCurrentProvider() // "anthropic"
modelID := ctx.GetCurrentModelID() // "claude-sonnet-4"
ModelCapabilities.Pricing reports registry token costs in US dollars per
million tokens, so a cost is tokens * rate / 1_000_000:
caps, _ := ctx.GetModelCapabilities("")
p := caps.Pricing
// p.Input, p.Output float64 — $ per 1M tokens
// p.CacheRead, p.CacheWrite float64 — valid only when the Has* flag is true
// p.HasCacheRead, p.HasCacheWrite bool
// p.Known bool
Always check Known before rendering a cost. It is false for local models and
custom OpenAI-compatible endpoints, where every rate is zero — without the flag
an unpriced model is indistinguishable from a free one. Likewise check
HasCacheRead rather than assuming a zero CacheRead means cache reads are
free.
Computing prompt-cache savings:
usage := ctx.GetSessionUsage()
if p.Known && p.HasCacheRead {
saved := float64(usage.TotalCacheReadTokens) * (p.Input - p.CacheRead) / 1000000
}
The same Pricing field is present on each entry from
ctx.GetAvailableModels().