| name | kit-extensions |
| description | Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event. |
Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, register and switch color themes, and more.
Extensions can be distributed via git repositories using kit install. Repos can contain single extensions or collections of multiple extensions.
Extension Structure
Every extension must export a package main with an Init(api ext.API) function:
package main
import "kit/ext"
func Init(api ext.API) {
}
The //go:build ignore tag prevents go build from compiling the file directly.
Extension Locations
Extensions are auto-loaded from these directories:
~/.config/kit/extensions/*.go (global, single files)
~/.config/kit/extensions/*/main.go (global, subdirectories)
.kit/extensions/*.go (project-local, single files)
.kit/extensions/*/main.go (project-local, subdirectories)
Or loaded explicitly:
kit -e path/to/extension.go
kit --extension path/to/extension.go
Import Path
Extensions import the Kit API as "kit/ext". The full standard library is available plus os/exec for subprocess spawning.
API Overview
The Init function receives an ext.API object for registering handlers, and event handlers receive an ext.Context with runtime capabilities.
Lifecycle Events
Kit provides 21 lifecycle events. Each handler receives an event struct and a Context.
Session Events
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
})
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
})
Agent Turn Events
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
return nil
})
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
})
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
})
api.OnLLMUsage(func(e ext.LLMUsageEvent, ctx ext.Context) {
})
Tool Events
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
return nil
})
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
})
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
})
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
return nil
})
Tool Call Input Streaming Events
These events fire during the LLM's tool argument generation phase, before the tool call is fully parsed and before OnToolCall fires. They enable UIs to show tool activity immediately rather than waiting for the full argument JSON to finish streaming.
api.OnToolCallInputStart(func(e ext.ToolCallInputStartEvent, ctx ext.Context) {
ctx.PrintInfo("Tool starting: " + e.ToolName)
})
api.OnToolCallInputDelta(func(e ext.ToolCallInputDeltaEvent, ctx ext.Context) {
})
api.OnToolCallInputEnd(func(e ext.ToolCallInputEndEvent, ctx ext.Context) {
})
Full tool lifecycle order: OnToolCallInputStart → OnToolCallInputDelta (repeated) → OnToolCallInputEnd → OnToolCall → OnToolExecutionStart → OnToolOutput (optional, repeated) → OnToolExecutionEnd → OnToolResult
Input Events
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
return nil
})
Streaming Events
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
})
Model Events
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
})
Context Filtering
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
return nil
})
Session Control Events
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
return nil
})
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
return nil
})
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
return nil
})
Custom Events
api.OnCustomEvent("event-name", func(data string) {
})
ctx.EmitCustomEvent("event-name", "payload")
Registering Tools
Tools are functions the LLM can invoke:
api.RegisterTool(ext.ToolDef{
Name: "current_time",
Description: "Get the current date and time",
Parameters: `{"type":"object","properties":{}}`,
Execute: func(input string) (string, error) {
return time.Now().Format(time.RFC3339), nil
},
})
For long-running tools with cancellation and progress:
api.RegisterTool(ext.ToolDef{
Name: "slow_task",
Description: "A long-running task with progress reporting",
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
for i := 0; i < 10; i++ {
if tc.IsCancelled() {
return "cancelled", nil
}
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
time.Sleep(time.Second)
}
return "done", nil
},
})
Parameters must be a JSON Schema string. The input argument is the JSON-encoded parameters from the LLM.
Registering Slash Commands
Commands are user-facing actions invoked with /name in the input:
api.RegisterCommand(ext.CommandDef{
Name: "echo",
Description: "Echo back the provided text",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.PrintInfo("You said: " + args)
return "", nil
},
Complete: func(prefix string, ctx ext.Context) []string {
return []string{"hello", "world"}
},
})
Slash commands run in a dedicated goroutine (not a tea.Cmd), so they can safely block on prompts, I/O, etc.
Registering Keyboard Shortcuts
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
})
Registering Options
Options are configurable values resolved from env vars, config, or defaults:
api.RegisterOption(ext.OptionDef{
Name: "my-setting",
Description: "Controls something",
Default: "false",
})
val := ctx.GetOption("my-setting")
ctx.SetOption("my-setting", "true")
Context API Reference
The ext.Context struct provides runtime capabilities via function fields.
Output
ctx.Print("plain text")
ctx.PrintInfo("styled info block")
ctx.PrintError("styled error block")
ctx.PrintBlock(ext.PrintBlockOpts{
Text: "content",
BorderColor: "#a6e3a1",
Subtitle: "my-ext",
})
ctx.RenderMessage("renderer-name", "content")
Message Injection
ctx.SendMessage("prompt text")
ctx.CancelAndSend("new prompt")
Widgets
Persistent UI elements displayed above or below the input area:
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Text: "Status: Active",
Markdown: false,
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1",
NoBorder: false,
},
Priority: 0,
})
ctx.RemoveWidget("my-widget")
Header and Footer
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Header"},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
ctx.RemoveHeader()
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Footer"},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
ctx.RemoveFooter()
Status Bar
ctx.SetStatus("key", "PLAN MODE", 10)
ctx.RemoveStatus("key")
Interactive Prompts
These block until the user responds (safe in slash commands and goroutines):
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Pick one:",
Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
}
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure?",
DefaultValue: false,
})
if !result.Cancelled {
}
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter name:",
Placeholder: "my-project",
Default: "",
})
if !result.Cancelled {
}
result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{
Message: "Select extensions to install:",
Options: []string{"git", "todo", "weather"},
DefaultSelected: []int{0, 1, 2},
})
if !result.Cancelled {
}
Overlay Dialogs
Modal dialogs with optional action buttons:
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Confirmation",
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 60,
MaxHeight: 20,
Anchor: ext.OverlayCenter,
Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
}
Editor Interceptor
Wrap the built-in text input with custom key handling and rendering:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if key == "ctrl+s" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
Render: func(width int, defaultContent string) string {
return "[custom] " + defaultContent
},
})
ctx.ResetEditor()
ctx.SetEditorText("prefilled")
EditorKeyAction types:
ext.EditorKeyPassthrough — let the default editor handle the key
ext.EditorKeyConsumed — swallow the key, do nothing
ext.EditorKeyRemap — remap to a different key: EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}
ext.EditorKeySubmit — submit text: EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}
UI Visibility
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
Session Data
stats := ctx.GetContextStats()
msgs := ctx.GetMessages()
path := ctx.GetSessionPath()
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type")
Session State (last-write-wins)
Key-value store scoped to the session, persisted to a sidecar file
(<session>.ext-state.json) outside the conversation tree. Reads are O(1)
(no branch walk), writes don't grow the JSONL, and the store is not
duplicated on fork. State is invisible to the LLM and survives session
resume. For ephemeral / in-memory sessions, state lives only in memory.
ctx.SetState("myext:budget-cap", "10.00")
val, ok := ctx.GetState("myext:budget-cap")
ctx.DeleteState("myext:budget-cap")
keys := ctx.ListState()
When to use which:
| Need | Use |
|---|
| Snapshot state ("current value of X") | SetState / GetState |
| Audit log / event history | AppendEntry / GetEntries |
| One-shot per-turn signal | enriched AgentEndEvent fields |
| Per-LLM-call observation | OnLLMUsage event |
Namespace keys with your extension name (e.g. "myext:budget-cap") to avoid
collisions across extensions.
Model Management
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels()
Tool Management
tools := ctx.GetAllTools()
ctx.SetActiveTools([]string{"read", "grep"})
ctx.SetActiveTools(nil)
LLM Completions
Make standalone LLM calls (bypasses the agent tool loop):
resp, err := ctx.Complete(ext.CompleteRequest{
Model: "",
System: "You are ...",
Prompt: "Summarize...",
MaxTokens: 1000,
OnChunk: func(chunk string) { },
})
TUI Suspension
Temporarily release the terminal for interactive subprocesses:
ctx.SuspendTUI(func() {
cmd := exec.Command("vim", "file.go")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
Themes
Register, switch, and list color themes at runtime:
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"},
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
})
err := ctx.SetTheme("neon")
names := ctx.ListThemes()
ThemeColorConfig fields:
| Field | Description |
|---|
Primary | Main brand/accent color |
Secondary | Secondary accent |
Success | Success states |
Warning | Warning states |
Error | Error/critical states |
Info | Informational states |
Text | Primary text |
Muted | Dimmed text |
VeryMuted | Very dimmed text |
Background | Base background |
Border | Panel borders |
MutedBorder | Subtle dividers |
System | System messages |
Tool | Tool-related elements |
Accent | Secondary highlight |
Highlight | Highlighted regions |
MdHeading | Markdown headings |
MdLink | Markdown links |
MdKeyword | Syntax: keywords |
MdString | Syntax: strings |
MdNumber | Syntax: numbers |
MdComment | Syntax: comments |
Each field is an ext.ThemeColor with Light and Dark hex strings. Kit ships 22 built-in themes: kitt, catppuccin, dracula, tokyonight, nord, gruvbox, monokai, solarized, github, one-dark, rose-pine, ayu, material, everforest, kanagawa, amoled, synthwave, vesper, flexoki, matrix, vercel, zenburn.
Users can also drop .yml/.yaml/.json theme files in ~/.config/kit/themes/ (global) or .kit/themes/ (project-local). Extension-registered themes take highest precedence.
Application Control
ctx.Exit()
err := ctx.ReloadExtensions()
Context Fields
ctx.SessionID
ctx.CWD
ctx.Model
ctx.Interactive
Tool Renderers
Customize how tool calls are displayed in the TUI:
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell",
BorderColor: "#89b4fa",
Background: "",
BodyMarkdown: true,
RenderHeader: func(toolArgs string, width int) string {
var args struct{ Command string `json:"command"` }
json.Unmarshal([]byte(toolArgs), &args)
return "$ " + args.Command
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "ERROR: " + toolResult
}
return toolResult
},
})
Message Renderers
Define named output styles for ctx.RenderMessage():
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
return " " + content
},
})
ctx.RenderMessage("success", "All tests passed")
Critical Yaegi Constraints
No Named Function References in Struct Fields
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
func myHandler(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
This applies to ALL struct fields that take function values: ToolDef.Execute, CommandDef.Execute, EditorConfig.HandleKey, EditorConfig.Render, ToolRenderConfig.RenderHeader, ToolRenderConfig.RenderBody, etc.
No Interfaces Across the Boundary
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
Package-Level Variables for State
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
package main
import "kit/ext"
var callCount int
var lastTool string
func Init(api ext.API) {
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
callCount++
lastTool = e.ToolName
return nil
})
}
Common Patterns
Pattern: Tool Call Blocking
Block dangerous operations by intercepting tool calls:
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "bash" {
var input struct{ Command string `json:"command"` }
json.Unmarshal([]byte(tc.Input), &input)
if strings.Contains(input.Command, "rm -rf") {
return &ext.ToolCallResult{
Block: true,
Reason: "Dangerous command blocked",
}
}
}
return nil
})
Pattern: System Prompt Injection
Augment the agent's behavior by injecting instructions:
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
prompt := "Always respond with bullet points."
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
Pattern: Background Processing with SendMessage
Run work in a goroutine and inject results back:
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command in the background",
Execute: func(args string, ctx ext.Context) (string, error) {
go func() {
out, err := exec.Command("sh", "-c", args).CombinedOutput()
if err != nil {
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
return
}
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
}()
return "Running in background...", nil
},
})
Pattern: Ephemeral Context Injection
Inject information into every LLM turn without persisting in session history:
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
data, err := os.ReadFile(".kit/context.md")
if err != nil {
return nil
}
injected := ext.ContextMessage{
Index: -1,
Role: "system",
Content: string(data),
}
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
Pattern: Live Widget Updates
Update a widget periodically from a goroutine:
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetWidget(ext.WidgetConfig{
ID: "clock",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
}()
})
Pattern: Custom Theme with Slash Command
Register a theme and provide a slash command shortcut to activate it:
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
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"},
})
})
api.RegisterCommand(ext.CommandDef{
Name: "neon",
Description: "Switch to the neon cyberpunk theme",
Execute: func(args string, ctx ext.Context) (string, error) {
if err := ctx.SetTheme("neon"); err != nil {
return "", err
}
return "Neon theme activated!", nil
},
})
Pattern: Spawning Kit as a Sub-Agent
Use ctx.SpawnSubagent to spawn an in-process child Kit instance. The subagent gets its own session, event bus, and agent loop, inherits the parent's active tools minus the subagent tool (no recursion), and does not load extensions. Its session is persisted by default (set NoSession: true for ephemeral runs).
Blocking mode — waits for completion:
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: "Analyze the test files and summarize coverage",
Model: "anthropic/claude-haiku-3-5-20241022",
SystemPrompt: "You are a test analysis expert.",
Timeout: 2 * time.Minute,
Blocking: true,
})
if err != nil {
ctx.PrintError("spawn failed: " + err.Error())
return
}
if result.Error != nil {
ctx.PrintError("subagent failed: " + result.Error.Error())
return
}
ctx.PrintInfo("Result:\n" + result.Response)
Background mode — returns immediately with a handle:
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: "Write unit tests for UserService",
OnOutput: func(chunk string) {
},
OnEvent: func(event ext.SubagentEvent) {
},
OnComplete: func(result ext.SubagentResult) {
ctx.SendMessage("Subagent finished:\n" + result.Response)
},
})
SubagentConfig fields:
| Field | Type | Description |
|---|
Prompt | string | Task instruction (required) |
Model | string | Override model ("provider/model"), empty = parent's |
SystemPrompt | string | Custom system prompt, empty = default |
Timeout | time.Duration | Execution limit, 0 = 5 minutes |
Blocking | bool | true = wait and return result; false (default) = background goroutine + handle |
NoSession | bool | Don't persist subagent session file |
SessionID | string | Resume an existing subagent session (from a previous SubagentResult.SessionID) for multi-turn follow-ups |
ParentSessionID | string | Override the parent session link (optional; defaults to the host's active persisted session) |
OnOutput | func(string) | Live assistant text chunk callback |
OnEvent | func(SubagentEvent) | Real-time event callback |
OnComplete | func(SubagentResult) | Completion callback |
SubagentResult fields:
| Field | Type | Description |
|---|
Response | string | Final text response |
Error | error | Non-nil on failure |
ExitCode | int | 0 on success, 1 on failure (runs in-process, mirrors Error) |
Elapsed | time.Duration | Total execution time |
Usage | *SubagentUsage | Token usage (InputTokens, OutputTokens) |
SessionID | string | Subagent's session ID (if persisted) |
You can also spawn Kit as a raw subprocess for simpler cases:
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
Testing Extensions
Kit provides a testing package to help you write unit tests for your extensions:
package main
import (
"testing"
"github.com/mark3labs/kit/pkg/extensions/test"
"github.com/mark3labs/kit/internal/extensions"
)
func TestMyExtension(t *testing.T) {
harness := test.New(t)
harness.LoadFile("my-ext.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
test.AssertPrinted(t, harness, "session started")
test.AssertWidgetSet(t, harness, "my-widget")
result, _ := harness.Emit(extensions.ToolCallEvent{ToolName: "dangerous"})
test.AssertBlocked(t, result, "not allowed")
}
Key testing patterns:
- Load extensions with
LoadFile() or LoadString() for inline code
- Emit events with
Emit() to trigger handlers
- Verify with 25+ assertion helpers:
AssertWidgetSet(), AssertToolRegistered(), AssertPrintInfo(), etc.
- Mock prompts by setting results on
harness.Context().SetPromptSelectResult()
- Test multiple scenarios per extension with isolated harness instances
See examples/extensions/tool-logger_test.go for a complete example with 14 test cases.
CLI Testing Commands
kit extensions validate
kit extensions list
kit -e path/to/extension.go
kit -e ext1.go -e ext2.go
kit --no-extensions
kit extensions init
Distributing Extensions via Git Repositories
Extensions can be distributed and installed from git repositories using kit install. This enables sharing extensions with others and maintaining versioned collections.
Repository Structure
Extensions support two organization patterns within a repo:
Single-file extensions (simple, standalone):
my-extension-repo/
├── weather.go # Single extension file
├── todo.go # Another extension
└── README.md # Installation and usage docs
Multi-file extensions (with main.go entry point):
my-extension-repo/
├── git-tools/
│ ├── main.go # Entry point
│ ├── helpers.go # Supporting code
│ └── config.go # Configuration
├── todo/
│ ├── main.go # Entry point
│ └── storage.go # Storage logic
└── README.md
Hybrid approach (single files + subdirectories with main.go):
my-extensions/
├── weather.go # Single file extension
├── calculator.go # Single file extension
├── git-tools/
│ ├── main.go # Multi-file extension
│ └── utils.go
└── README.md
Installing from Git
Users install extensions using the kit install command:
kit install github.com/user/repo
kit install github.com/user/repo@v1.0.0
kit install github.com/user/repo@main
kit install github.com/user/repo@abc1234
kit install github.com/user/repo --local
kit install github.com/user/collection --select
Supported URL formats:
github.com/user/repo — Shorthand (defaults to HTTPS)
git:github.com/user/repo — Git prefix format
https://github.com/user/repo — HTTPS URL
ssh://git@github.com/user/repo — SSH URL
git@github.com:user/repo — SSH shorthand
Managing Installed Extensions
kit install github.com/user/repo --update
kit install github.com/user/repo --uninstall
kit extensions list
kit extensions validate
Extension Selection
For repos containing multiple extensions, users can select which to install:
kit install github.com/user/collection --select
This prompts the user to choose which extensions to install. Selected extensions are recorded in the manifest, and only those are loaded at runtime (others in the repo are ignored).
README Template for Extension Repos
Include this in your extension repo's README.md:
# My Kit Extensions
A collection of extensions for [Kit](https://github.com/mark3labs/kit).
## Installation
### Install all extensions
\`\`\`bash
kit install github.com/username/repo
\`\`\`
### Install specific extensions
\`\`\`bash
kit install github.com/username/repo --select
\`\`\`
### Install locally in a project
\`\`\`bash
kit install github.com/username/repo --local
\`\`\`
## Extensions
### Extension Name
Description of what it does.
- **Path**: `./ext-name/main.go` or `./ext-name.go`
- **Commands**: `/command-name`
- **Tools**: `tool_name`
## Requirements
- Kit vX.Y.Z+
- Any other dependencies
## Update
\`\`\`bash
kit install github.com/username/repo --update
\`\`\`
Storage Locations
Installed extensions are stored at:
- Global:
~/.local/share/kit/git/<host>/<owner>/<repo>/
- Project-local:
./.kit/git/<host>/<owner>/<repo>/
- Manifest:
packages.json in respective directories
Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
package main
import (
"strings"
"kit/ext"
)
func Init(api ext.API) {
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if strings.ToLower(ctx.GetOption("plan")) == "true" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
Focus on understanding, analysis, and generating plans.`
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
}
func applyMode(ctx ext.Context, active bool, tools []string) {
if active {
ctx.SetActiveTools(tools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON")
} else {
ctx.SetActiveTools(nil)
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF")
}
}
Bridged SDK APIs (New)
Extensions can now 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:
node := ctx.GetTreeNode("entry-id")
branch := ctx.GetCurrentBranch()
children := ctx.GetChildren("entry-id")
result := ctx.NavigateTo("entry-id")
summary := ctx.SummarizeBranch("from-id", "to-id")
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
Skill Loading
Load and inject skills dynamically at runtime:
result := ctx.DiscoverSkills()
skill, err := ctx.LoadSkill("/path/to/skill.md")
result := ctx.LoadSkillsFromDir("/path/to/skills")
err := ctx.InjectSkillAsContext("skill-name")
err := ctx.InjectRawSkillAsContext("/path/to/skill.md")
skills := ctx.GetAvailableSkills()
Template Parsing
Parse and render templates with variable substitution:
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
vars := map[string]string{"name": "Alice", "place": "Kit"}
rendered := ctx.RenderTemplate(tpl, vars)
pattern := ext.ArgumentPattern{
Positional: []string{"command", "target"},
Rest: "args",
Flags: map[string]string{"--loop": "loop", "-f": "force"},
}
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
args := ctx.SimpleParseArguments("deploy staging --force", 2)
matches := ctx.EvaluateModelConditional("claude-*")
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
rendered := ctx.RenderWithModelConditionals(content)
Model Resolution
Resolve model fallback chains and query capabilities:
result := ctx.ResolveModelChain([]string{
"anthropic/claude-opus-4",
"anthropic/claude-sonnet-4",
"openai/gpt-4o",
})
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4")
provider := ctx.GetCurrentProvider()
modelID := ctx.GetCurrentModelID()
Key Files for Reference