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

533 lines
17 KiB
Markdown

---
title: Capabilities
description: 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
```go
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:
```go
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:
```go
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:
```go
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:
```go
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:
```go
ctx.SetStatus("mode", "Planning")
ctx.RemoveStatus("mode")
```
## Shortcuts
Global keyboard shortcuts:
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+t",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handle shortcut
})
```
## Overlays
Modal dialogs with markdown content:
```go
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:
```go
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:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "custom",
Render: func(content string) string {
return ">> " + content
},
})
```
## Editor interceptors
Handle key events and wrap the editor's rendering:
```go
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:
```go
// 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:
```go
api.RegisterOption(ext.OptionDef{
Name: "auto-commit",
Description: "Automatically commit on shutdown",
DefaultValue: "false",
})
```
## Subagents
Spawn in-process child Kit instances:
```go
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:
```go
// 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:
```go
response := ctx.Complete(ext.CompleteRequest{
Prompt: "Summarize this in one sentence: " + content,
})
```
## Themes
Register and switch color themes at runtime:
```go
// 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](/themes) for the full theme file format, built-in themes, and color reference.
## Custom events
Inter-extension communication:
```go
// 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:
```go
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:
```go
// 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:
```go
// 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:
```go
// 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:
```go
// 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"
```