skills/mark3labs/kit/kit-extensions

kit-extensions

SKILL.md

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, and more.

Extension Structure

Every extension must export a package main with an Init(api ext.API) function:

//go:build ignore

package main

import "kit/ext"

func Init(api ext.API) {
    // Register event handlers, tools, commands, etc.
}

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 18 lifecycle events. Each handler receives an event struct and a Context.

Session Events

// Fired when session is loaded/created.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
    // e.SessionID string
})

// Fired when Kit is shutting down. Use for cleanup.
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
    // No fields.
})

Agent Turn Events

// Before agent starts processing. Can inject system prompt or text.
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
    // e.Prompt string
    // Return nil to pass through.
    // Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
    // Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
    return nil
})

// Agent loop has started.
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
    // e.Prompt string
})

// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
    // e.Response string
    // e.StopReason string — "completed", "cancelled", "error"
})

Tool Events

// Before a tool executes. Can block the call.
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
    // e.ToolName string
    // e.ToolCallID string
    // e.Input string — JSON-encoded parameters
    // e.Source string — "llm" or "user"
    // Return nil to allow.
    // Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
    return nil
})

// Tool execution started (informational only).
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
    // e.ToolName string
})

// Tool execution ended (informational only).
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
    // e.ToolName string
})

// After a tool returns. Can modify the result.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
    // e.ToolName string
    // e.Input string
    // e.Content string
    // e.IsError bool
    // Return nil to pass through.
    // Return &ext.ToolResultResult{Content: &s} to replace content.
    // Return &ext.ToolResultResult{IsError: &b} to change error status.
    return nil
})

Input Events

// User submitted input. Can handle or transform it.
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
    // e.Text string
    // e.Source string — "interactive", "cli", "script", "queue"
    // Return nil to pass through to agent.
    // Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
    // Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
    return nil
})

Streaming Events

api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
    // e.Chunk string — streaming text chunk
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
    // e.Content string — full message content
})

Model Events

api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
    // e.NewModel string
    // e.PreviousModel string
    // e.Source string — "extension" or "user"
})

Context Filtering

// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
    // e.Messages []ext.ContextMessage
    // Each ContextMessage has: Index int, Role string, Content string
    // Index -1 means a new injected message (not from session).
    // Return nil to pass through.
    // Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
    return nil
})

Session Control Events

// Before forking the session tree. Can cancel.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
    // e.TargetID string, e.IsUserMessage bool, e.UserText string
    return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
})

// Before switching/clearing session. Can cancel.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
    // e.Reason string — "new" or "clear"
    return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
})

// Before context compaction. Can cancel.
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
    // e.EstimatedTokens, e.ContextLimit int
    // e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
    return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
})

Custom Events

// Subscribe to custom events emitted by other extensions.
api.OnCustomEvent("event-name", func(data string) {
    // data is arbitrary string payload
})

// Emit from Context:
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
    },
    // Optional tab-completion:
    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) {
    // handler runs when shortcut is pressed
})

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

// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
val := ctx.GetOption("my-setting")

// Set at runtime:
ctx.SetOption("my-setting", "true")

Context API Reference

The ext.Context struct provides runtime capabilities via function fields.

Output

ctx.Print("plain text")                    // plain output
ctx.PrintInfo("styled info block")         // bordered info block
ctx.PrintError("styled error block")       // red error block
ctx.PrintBlock(ext.PrintBlockOpts{         // custom styled block
    Text:        "content",
    BorderColor: "#a6e3a1",
    Subtitle:    "my-ext",
})
ctx.RenderMessage("renderer-name", "content")  // use a registered message renderer

Message Injection

ctx.SendMessage("prompt text")     // inject message and trigger agent turn (queued)
ctx.CancelAndSend("new prompt")   // cancel current turn, clear queue, send new message

Widgets

Persistent UI elements displayed above or below the input area:

ctx.SetWidget(ext.WidgetConfig{
    ID:        "my-widget",
    Placement: ext.WidgetAbove,  // or ext.WidgetBelow
    Content:   ext.WidgetContent{
        Text:     "Status: Active",
        Markdown: false,  // set true for markdown rendering
    },
    Style: ext.WidgetStyle{
        BorderColor: "#a6e3a1",  // hex color
        NoBorder:    false,
    },
    Priority: 0,  // lower values render first
})

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)  // key, text, priority (lower = further left)
ctx.RemoveStatus("key")

Interactive Prompts

These block until the user responds (safe in slash commands and goroutines):

// Selection list
result := ctx.PromptSelect(ext.PromptSelectConfig{
    Message: "Pick one:",
    Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
    // result.Value string, result.Index int
}

// Yes/No confirmation
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
    Message:      "Are you sure?",
    DefaultValue: false,
})
if !result.Cancelled {
    // result.Value bool
}

// Text input
result := ctx.PromptInput(ext.PromptInputConfig{
    Message:     "Enter name:",
    Placeholder: "my-project",
    Default:     "",
})
if !result.Cancelled {
    // result.Value string
}

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,          // 0 = 60% of terminal width
    MaxHeight: 20,        // 0 = 80% of terminal height
    Anchor:  ext.OverlayCenter,  // or ext.OverlayTopCenter, ext.OverlayBottomCenter
    Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
    // result.Action string, result.Index int
}

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()                  // remove interceptor
ctx.SetEditorText("prefilled")     // set editor text content

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()     // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
msgs := ctx.GetMessages()          // []ext.SessionMessage on current branch
path := ctx.GetSessionPath()       // file path of session JSONL

// Persist custom data in the session tree:
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type")  // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}

Model Management

err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels()  // []ext.ModelInfoEntry

Tool Management

tools := ctx.GetAllTools()              // []ext.ToolInfo{Name, Description, Source, Enabled}
ctx.SetActiveTools([]string{"read", "grep"})  // restrict to these tools only
ctx.SetActiveTools(nil)                 // re-enable all tools

LLM Completions

Make standalone LLM calls (bypasses the agent tool loop):

resp, err := ctx.Complete(ext.CompleteRequest{
    Model:    "",             // empty = current model
    System:   "You are ...",  // optional system prompt
    Prompt:   "Summarize...", // the prompt
    MaxTokens: 1000,          // 0 = provider default
    OnChunk:  func(chunk string) { /* streaming */ },
})
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model

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

Application Control

ctx.Exit()                        // graceful shutdown
err := ctx.ReloadExtensions()     // hot-reload all extensions from disk

Context Fields

ctx.SessionID    // string
ctx.CWD          // string — current working directory
ctx.Model        // string — active model name
ctx.Interactive  // bool — true if running in TUI mode

Tool Renderers

Customize how tool calls are displayed in the TUI:

api.RegisterToolRenderer(ext.ToolRenderConfig{
    ToolName:     "bash",
    DisplayName:  "Shell",           // replaces auto-capitalized name
    BorderColor:  "#89b4fa",
    Background:   "",
    BodyMarkdown: true,              // render body through markdown
    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  // green checkmark prefix
    },
})

// Usage in handlers:
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:

// WRONG - will silently return zero values:
func myHandler(key, text string) ext.EditorKeyAction {
    return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})

// CORRECT - use anonymous closure:
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,  // -1 = new message, not from session
        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: Spawning Kit as a Sub-Agent

Extensions can spawn Kit as a subprocess for delegation:

kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"

Key flags: --quiet (stdout only, no TUI), --no-session (ephemeral), --no-extensions (prevent recursion), --system-prompt (string or file path).


Testing Extensions

# Validate syntax of all discovered extensions
kit extensions validate

# List loaded extensions
kit extensions list

# Run with a specific extension
kit -e path/to/extension.go

# Run with multiple extensions
kit -e ext1.go -e ext2.go

# Disable all extensions
kit --no-extensions

# Generate an example extension scaffold
kit extensions init

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:

//go:build ignore

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

Key Files for Reference

  • internal/extensions/api.go — Complete API type definitions
  • internal/extensions/runner.go — Event dispatch and state management
  • internal/extensions/loader.go — Yaegi interpreter setup
  • internal/extensions/symbols.go — All types exported to extensions
  • examples/extensions/ — 25+ working example extensions
Weekly Installs
4
Repository
mark3labs/kit
GitHub Stars
7
First Seen
1 day ago
Installed on
cline4
gemini-cli4
github-copilot4
codex4
kimi-cli4
cursor4