Files
kit/www/pages/extensions/capabilities.md
T
Ed Zynda 49f8b485be feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53) (#54)
* feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53)

Three additive primitives to the extension API:

- OnLLMUsage event: per-LLM-call token + cost deltas attributed to the
  specific model/provider used for each round-trip. Derived from the SDK
  StepFinishEvent in the extension bridge. Enables accurate budget
  enforcement between calls instead of only at turn boundaries.

- ctx.SetState / GetState / DeleteState / ListState: session-scoped,
  last-write-wins key-value store backed by a sidecar file
  (<session>.ext-state.json) outside the conversation tree. Reads are
  O(1), writes don't grow the JSONL, and the store is not duplicated on
  fork. State is preserved across hot-reloads.

- Enriched AgentEndEvent: ToolCallCount, ToolNames, LLMCallCount, token
  deltas (input/output/cache-read/cache-write), CostDelta, and
  DurationMs populated by a per-turn aggregator. Existing handlers
  reading only Response/StopReason are unaffected.

Includes unit tests for the state store, LLMUsage registration,
enriched AgentEndEvent, turn aggregator, llmUsageMeta, and sidecar path
derivation. Adds examples/extensions/usage-budget.go demoing all three
primitives together. Documents the additions in README, the docs site
(extensions overview, capabilities, examples), and the kit-extensions
and kit-sdk skill guides.

Fixes #53

* fix(extensions): address review feedback on state store and llmUsageMeta

- Serialize SetState/DeleteState saver invocations through a new saverMu
  so overlapping atomic-rename writes can no longer race on the shared
  .tmp file and persist an older snapshot after a newer one.
- LoadStateFromFile now clears the in-memory store when the sidecar is
  missing or empty, matching the documented "replace … with its
  contents" contract. This makes session-switching safe by preventing
  keys from a prior session leaking into a new one. Tests updated to
  cover both the missing-file and empty-file cases.
- llmUsageMeta now detects Anthropic OAuth credentials and returns
  Cost=0, matching the comment and the existing usage_tracker behavior
  for OAuth users. Mirrors the OAuth detection already used in
  cmd/extension_context.go.
- Document the single-in-flight-turn assumption baked into the
  per-turn aggregator with a clear migration path (per-turn ID) for if
  concurrent turns ever become a supported use case.

* fix(extensions): release saverMu on panic in state store

Extract a runSaver helper that locks saverMu and defers Unlock before
invoking the persistence callback. Without the deferred Unlock, a panic
inside the saver (e.g. disk full mid-write) would leave saverMu held
forever and deadlock the next SetState/DeleteState. Both SetState and
DeleteState now route through the helper. New TestRunner_State_Saver
PanicReleasesSaverMu reproduces the deadlock window with a 2s deadline
and proves the mutex is released after a panic.
2026-06-09 16:18:10 +03:00

17 KiB

title, description
title description
Capabilities All extension capabilities — lifecycle events, tools, commands, widgets, and more.

Extension Capabilities

Lifecycle events

Extensions can hook into 27 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
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

Example

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)

Tools

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

Commands

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

Widgets

Add persistent status displays above or below the input area:

ctx.SetWidget(ext.WidgetConfig{
    ID:       "token-count",
    Position: "bottom",
    Content:  ext.WidgetContent{Text: "Tokens: 1,234"},
})

// Update later
ctx.SetWidget(ext.WidgetConfig{
    ID:       "token-count",
    Position: "bottom",
    Content:  ext.WidgetContent{Text: "Tokens: 2,456"},
})

// Remove
ctx.RemoveWidget("token-count")

Headers and footers

Persistent content above and below the conversation:

ctx.SetHeader(ext.HeaderFooterConfig{
    Content: ext.WidgetContent{Text: "Project: my-app | Branch: main"},
})

ctx.SetFooter(ext.HeaderFooterConfig{
    Content: ext.WidgetContent{Text: "Plan Mode (read-only)"},
})

Status bar

Custom status bar entries:

ctx.SetStatus("mode", "Planning")
ctx.RemoveStatus("mode")

Shortcuts

Global keyboard shortcuts:

api.RegisterShortcut(ext.ShortcutDef{
    Key:         "ctrl+t",
    Description: "Toggle plan mode",
}, func(ctx ext.Context) {
    // handle shortcut
})

Overlays

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

Tool renderers

Customize how specific tool calls are displayed in the TUI:

api.RegisterToolRenderer(ext.ToolRenderConfig{
    ToolName: "bash",
    Render: func(name, args, result string, isError bool) string {
        return "$ " + args + "\n" + result
    },
})

Message renderers

Custom rendering for assistant messages:

api.RegisterMessageRenderer(ext.MessageRendererConfig{
    Name: "custom",
    Render: func(content string) string {
        return ">> " + content
    },
})

Editor interceptors

Handle key events and wrap the editor's rendering:

ctx.SetEditor(ext.EditorConfig{
    HandleKey: func(key, text string) ext.EditorKeyAction {
        if key == "escape" {
            return ext.EditorKeyAction{Handled: true}
        }
        return ext.EditorKeyAction{Handled: false}
    },
})

Interactive prompts

Select, confirm, input, and multi-select dialogs:

// Single select
response := ctx.PromptSelect(ext.PromptSelectConfig{
    Title:   "Choose a model",
    Options: []string{"claude-sonnet", "gpt-4o", "llama3"},
})

// Confirm
confirmed := ctx.PromptConfirm(ext.PromptConfirmConfig{
    Title: "Delete this file?",
})

// Text input
name := ctx.PromptInput(ext.PromptInputConfig{
    Title:       "Enter project name",
    Placeholder: "my-project",
})

Options

Register configurable extension options:

api.RegisterOption(ext.OptionDef{
    Name:         "auto-commit",
    Description:  "Automatically commit on shutdown",
    DefaultValue: "false",
})

Subagents

Spawn in-process child Kit instances:

result := ctx.SpawnSubagent(ext.SubagentConfig{
    Task:         "Analyze the test files and summarize coverage",
    Model:        "anthropic/claude-haiku-latest",
    SystemPrompt: "You are a test analysis expert.",
})

Monitoring subagents spawned by the main agent

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.

LLM completion

Make direct model calls without going through the agent loop:

response := ctx.Complete(ext.CompleteRequest{
    Prompt: "Summarize this in one sentence: " + content,
})

Themes

Register and switch 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()

See Themes for the full theme file format, built-in themes, and color reference.

Custom events

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

Session state

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.

When to use which persistence primitive

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.

Bridged SDK APIs

Extensions can access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.

Tree Navigation

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

Skill Loading

Load and inject skills dynamically at runtime:

// Discover skills from standard locations
result := ctx.DiscoverSkills()  // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/

// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md")  // (*ext.Skill, error string)
// skill.Name, skill.Description, skill.Content, 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)
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

Template Parsing

Parse and render templates with variable substitution:

// Parse a template to extract {{variables}}
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
// 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

Model Resolution

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

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