Restructure the codebase so the CLI app consumes the SDK rather than the SDK wrapping CLI internals. This eliminates the circular dependency (sdk -> cmd -> sdk) and establishes pkg/kit as the canonical API. Key changes: - Create pkg/kit/ with InitConfig, SetupAgent, BuildProviderConfig extracted from cmd/root.go and cmd/setup.go as parameterized functions - Move sdk/kit.go -> pkg/kit/kit.go (remove cmd import, use local calls) - Move sdk/types.go -> pkg/kit/types.go - Move main.go -> cmd/kit/main.go (standard Go project layout) - cmd/root.go and cmd/setup.go now delegate to pkg/kit, injecting CLI-specific state (quietFlag) via the Quiet field on AgentSetupOptions - Add setSDKDefaults() for cobra-free SDK usage (viper defaults) - Fix .gitignore: kit -> /kit (was blocking cmd/kit/ and pkg/kit/) - Update .goreleaser.yaml, Taskfile.yml, AGENTS.md, contribute/build.sh, README.md for new cmd/kit entrypoint and pkg/kit import paths - Add plans/ with 10 detailed SDK revamp plans and Taskfile.yml - Delete sdk/ directory entirely
8.8 KiB
Plan 09: Extension Hook System
Priority: P3 Effort: High Goal: Expose Go-native interception hooks in the SDK. The Kit CLI app registers its own extension handlers as SDK hooks, proving the API is complete.
Background
Pi has 20+ lifecycle hooks. Kit already has an internal extension system (internal/extensions/) with 13 event types, a Runner for dispatch, and tool wrapping. But none of this is accessible through the SDK.
This plan exposes hooks in the SDK and migrates the app's extension dispatch to use them — making the CLI the proof that the hook API is production-ready.
Prerequisites
- Plan 00 (Create
pkg/kit/) - Plan 01 (Export tools — for custom tool registration)
- Plan 02 (Richer type exports)
- Plan 03 (Event subscriber system — observation layer)
Design: Events vs Hooks
| Events (Plan 03) | Hooks (This Plan) | |
|---|---|---|
| Purpose | Observe | Intercept |
| Can block? | No | Yes (BeforeToolCall) |
| Can modify? | No | Yes (AfterToolResult) |
| Pattern | Subscribe(func(Event)) |
OnBeforeToolCall(func(Hook) *Result) |
| Priority | N/A | High/Normal/Low ordering |
Both coexist — events fire regardless; hooks run before/after and can alter execution.
Step-by-Step
Step 1: Define hook input/result types
File: pkg/kit/hooks.go (new)
package kit
type HookPriority int
const (
HookPriorityHigh HookPriority = 0
HookPriorityNormal HookPriority = 50
HookPriorityLow HookPriority = 100
)
// BeforeToolCall — can block tool execution
type BeforeToolCallHook struct {
ToolName string
ToolArgs string
}
type BeforeToolCallResult struct {
Block bool
Reason string
}
// AfterToolResult — can modify tool output
type AfterToolResultHook struct {
ToolName string
ToolArgs string
Result string
IsError bool
}
type AfterToolResultResult struct {
Result *string // non-nil overrides
IsError *bool // non-nil overrides
}
// BeforeTurn — can modify prompt, inject context
type BeforeTurnHook struct {
Prompt string
}
type BeforeTurnResult struct {
Prompt *string // override prompt
SystemPrompt *string // prepend system message
InjectText *string // prepend user message (context)
}
// AfterTurn — observe completion
type AfterTurnHook struct {
Response string
Error error
}
Step 2: Implement generic hook registry with priority ordering
type hookRegistry[In any, Out any] struct {
mu sync.RWMutex
hooks []hookEntry[In, Out]
next int
}
type hookEntry[In any, Out any] struct {
id int
priority HookPriority
handler func(In) *Out
}
func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func() { ... }
func (hr *hookRegistry[In, Out]) run(input In) *Out { ... } // first non-nil result wins
Step 3: Add registries to Kit struct and expose registration methods
type Kit struct {
// ... existing fields ...
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
beforeTurn *hookRegistry[BeforeTurnHook, BeforeTurnResult]
afterTurn *hookRegistry[AfterTurnHook, struct{}]
}
func (m *Kit) OnBeforeToolCall(p HookPriority, h func(BeforeToolCallHook) *BeforeToolCallResult) func() { ... }
func (m *Kit) OnAfterToolResult(p HookPriority, h func(AfterToolResultHook) *AfterToolResultResult) func() { ... }
func (m *Kit) OnBeforeTurn(p HookPriority, h func(BeforeTurnHook) *BeforeTurnResult) func() { ... }
func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() { ... }
Step 4: Wire hooks into Prompt flow
In Prompt():
- Run
beforeTurnhooks — can modify prompt, inject system/context messages - Wrap tools with
hookedToolthat runsbeforeToolCall(can block) andafterToolResult(can modify) - Run
afterTurnhooks after generation
Step 5: Tool wrapping via hooks
type hookedTool struct {
inner fantasy.AgentTool
kit *Kit
}
func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
// 1. BeforeToolCall hook — can block
result := h.kit.beforeToolCall.run(BeforeToolCallHook{...})
if result != nil && result.Block { return error }
// 2. Execute actual tool
resp, err := h.inner.Run(ctx, call)
// 3. AfterToolResult hook — can modify
after := h.kit.afterToolResult.run(AfterToolResultHook{...})
if after != nil { /* apply overrides */ }
return resp, err
}
The hook wrapper composes with the existing extension wrapper:
// Extension wrapper runs first (inner), SDK hooks run outside (outer)
tools = extensionWrapper(tools) // extensions wrap
tools = m.wrapToolsWithHooks(tools) // SDK hooks wrap on top
Step 6: App-as-Consumer — Extension system registers as SDK hooks
This is the payoff step. The app's extension Runner currently dispatches events directly in internal/app/app.go:executeStep(). After this plan, extensions register as SDK hooks during initialization:
File: pkg/kit/setup.go or a new pkg/kit/extensions_bridge.go
// bridgeExtensions registers extension handlers as SDK hooks.
// This makes the extension system a consumer of the SDK hook API.
func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
// Extension BeforeAgentStart → SDK BeforeTurn hook
if runner.HasHandlers(extensions.BeforeAgentStart) {
m.OnBeforeTurn(HookPriorityNormal, func(h BeforeTurnHook) *BeforeTurnResult {
result, _ := runner.Emit(extensions.BeforeAgentStartEvent{Prompt: h.Prompt})
if r, ok := result.(extensions.BeforeAgentStartResult); ok {
return &BeforeTurnResult{
SystemPrompt: r.SystemPrompt,
InjectText: r.InjectText,
}
}
return nil
})
}
// Extension Input → SDK BeforeTurn hook (higher priority, runs first)
if runner.HasHandlers(extensions.Input) {
m.OnBeforeTurn(HookPriorityHigh, func(h BeforeTurnHook) *BeforeTurnResult {
result, _ := runner.Emit(extensions.InputEvent{Text: h.Prompt})
if r, ok := result.(extensions.InputResult); ok {
if r.Action == "transform" {
return &BeforeTurnResult{Prompt: &r.Text}
}
}
return nil
})
}
// Extension ToolCall → SDK BeforeToolCall hook
// (Already handled by extensions.WrapToolsWithExtensions, but could also
// be bridged here for SDK-only consumers)
}
Called during Kit.New():
if setupResult.ExtRunner != nil {
k.bridgeExtensions(setupResult.ExtRunner)
}
Migration path:
- Phase 1 (this plan): Bridge existing extensions as SDK hooks
- Phase 2 (future):
executeStep()in app.go uses only SDK hooks, removes direct runner calls - Phase 3 (future): Extension runner emits SDK events/hooks natively instead of its own types
Step 7: Custom tool registration via Options
type Options struct {
// ... existing fields ...
ExtraTools []Tool // Additional tools for the agent
}
Step 8: Write tests and verify
go build -o output/kit ./cmd/kit
go test -race ./...
Files Changed Summary
| Action | File | Change |
|---|---|---|
| CREATE | pkg/kit/hooks.go |
Hook types, registry, registration methods |
| EDIT | pkg/kit/kit.go |
Hook registries, tool wrapper, Prompt hook invocation |
| CREATE | pkg/kit/extensions_bridge.go |
Bridge extension events to SDK hooks |
| EDIT | internal/app/app.go |
Gradual migration to use SDK hooks |
API Surface After This Plan
// Block dangerous tool calls
k.OnBeforeToolCall(kit.HookPriorityHigh, func(h kit.BeforeToolCallHook) *kit.BeforeToolCallResult {
if h.ToolName == "bash" && isDangerous(h.ToolArgs) {
return &kit.BeforeToolCallResult{Block: true, Reason: "dangerous"}
}
return nil
})
// Modify tool results
k.OnAfterToolResult(kit.HookPriorityNormal, func(h kit.AfterToolResultHook) *kit.AfterToolResultResult {
sanitized := redact(h.Result)
return &kit.AfterToolResultResult{Result: &sanitized}
})
// Inject context before each turn
k.OnBeforeTurn(kit.HookPriorityNormal, func(h kit.BeforeTurnHook) *kit.BeforeTurnResult {
ctx := loadProjectContext()
return &kit.BeforeTurnResult{InjectText: &ctx}
})
Verification Checklist
- BeforeToolCall hooks can block tool calls
- AfterToolResult hooks can modify results
- BeforeTurn hooks can modify prompts and inject context
- Priority ordering works correctly
- Unregister removes hooks
- Extension system bridges to SDK hooks
- Hooks compose with existing extension wrapper
- Thread-safe under concurrent access