agenthooks

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 22 Imported by: 0

README


agenthooks

Author coding-agent hooks once in Go; run them on Claude Code, Cursor, OpenAI Codex, Gemini CLI, OpenCode, and Kimi Code.

Go Doc Release Go Report Card
Go Version Platform Support GitHub stars
Built by Speakeasy Software License

One clear interface, zero data-fidelity loss: the library owns the wire (JSON dialects, exit codes, stderr discipline, provider quirks), the consumer owns the logic. See DESIGN.md for the full design and the provider research behind it.

package main

import (
	"context"

	"github.com/speakeasy-api/agenthooks"
)

func main() {
	r := agenthooks.New(
		agenthooks.WithPolicy(agenthooks.Policy{
			Fail:        agenthooks.FailClosed,
			AskFallback: agenthooks.FallbackDeny,
		}),
	)

	r.OnToolPre(func(ctx context.Context, e *agenthooks.ToolPreEvent) (agenthooks.ToolPreDecision, error) {
		if e.Tool.Canonical == agenthooks.ToolShell {
			return agenthooks.AskUser("shell commands need confirmation"), nil
		}
		return agenthooks.NoDecision(), nil
	})

	// Fidelity escape hatch: every event, mapped or not, raw payload intact.
	r.OnAny(func(ctx context.Context, e *agenthooks.Event) error {
		agenthooks.Logger(ctx).Info("event", "provider", e.Provider, "native", e.NativeName)
		return nil
	})

	agenthooks.Main(r)
}

Packages

Package Purpose
agenthooks Envelope, decisions, capability matrix, policy, runtime (Main/Run), quirk registry
provider/{claudecode,codex,cursor,gemini,opencode,kimicode} Complete typed native structs with unknown-field capture — the fidelity guarantee
install One Go Manifest → correct hooks.json / settings.json / config.toml / plugin scaffolding per provider, workarounds baked in
transcript Best-effort JSONL transcript readers
agenthookstest Fixture corpus, in-process harness, fake-provider spawner
e2e Opt-in end-to-end suite driving real local agent CLIs (AGENTHOOKS_E2E=1 go test ./e2e)
examples/secretguard Working example: detect secrets in tool inputs, prompt the user to accept the risk or block

Installing hooks

m := install.Manifest{
	Command: []string{"/usr/local/bin/myhooks"},
	Hooks: []install.HookSpec{
		{Kind: agenthooks.KindToolPre, Blocking: true, Timeout: 30 * time.Second},
		{Kind: agenthooks.KindStop, Blocking: true},
	},
	Identity: install.Identity{Name: "myhooks", Version: "1.0.0"},
	Fail:     agenthooks.FailClosed,
}
err := install.Install(ctx, m, install.Target{
	Provider: agenthooks.ProviderClaudeCode,
	Scope:    install.ScopeProject,
	Dir:      repoRoot,
})

Generated configs bake in the argv contract (mybinary agenthooks run --provider=...), per-provider timeout units, async workarounds (sync Stop on Claude cowork, backgrounder wrapper on Codex), Cursor failClosed, and Codex trust-hash pre-seeding.

Semantics worth knowing

  • NoDecision() != Allow(): an empty-opinion response defers to the provider's own permission flow; it never force-allows.
  • Capability degradation is explicit: Policy.Unsupported chooses Degrade (nearest supported intent, logged) or Strict (handler error → Policy.Fail). Check e.Can(agenthooks.CapAsk) when you care.
  • Event.Raw is the verbatim provider payload, always. Unknown fields decode into Extra on every native struct. Normalization is a projection.
  • Provider gaps are backfilled best-effort: Kimi's and Cursor's print modes never fire their prompt-submitted hooks, so the runner synthesizes a reporting-only prompt.submitted before the next event that implies one — once per recovered prompt per session, so resumed headless turns (-p --resume) backfill again. Backfilled events carry Backfilled: true, a nil Raw (nothing is fabricated), no capabilities, and any returned decision is discarded — the prompt already reached the model. To gate on a recovered prompt, record what the PromptSubmitted handler saw and deny in the triggering event's handler: the backfill dispatches in the same process immediately before it. The prompt text is recovered best-effort — from Kimi's session store; on Cursor from the session transcript every hook payload names (covering stdin-piped prompts), falling back to the agent process's own argv. Disable with WithoutBackfill().
  • MCP tool calls carry the target server's transport: MCPCall.URL/Command come from Cursor and Gemini MCP payloads and are otherwise resolved from the provider's own MCP config files (.mcp.json, ~/.claude.json, ~/.codex/config.toml, .cursor/mcp.json, .gemini/settings.json plus extension manifests, .kimi-code/mcp.json, ~/.kimi/mcp.json, opencode.json(c)). OpenCode's shim uses the running server's resolved inventory, including custom, inline, remote, and managed configuration. Codex replays launch -c overrides and profiles into a detached codex mcp list --json warm, keyed by the recovered launch context. On Claude Code, servers absent from config files (plugins, claude.ai connectors) are attributed via claude mcp list, started as a detached SessionStart warm in the launching process's project and configuration context. The first MCP hook waits for that same context's in-flight snapshot only when discovery has not finished. On Claude Code 2.1.214+, launch-only --mcp-config, --strict-mcp-config, --settings, --setting-sources, --plugin-dir, --bare, and --safe-mode semantics are recovered through CLAUDE_PID; older versions fall back to the ordinary on-disk context. Inventories are cached briefly by a secret-safe context digest under the user cache directory and replaced on refresh so removals propagate. Remote --plugin-url servers stay unattributed rather than being refetched with potentially different code during hook dispatch. Config-resolved transport is flagged FromConfig and is best-effort — ambiguous or unrecoverable matches stay empty. Disable with WithoutMCPResolution() (everything) or WithoutMCPListFallback() (provider CLI probes).
  • The quirk registry (agenthooks.Quirks()) is the machine-readable list of provider glue this library hides, and doubles as the conformance-test plan.

Contributing

This repository is maintained by Speakeasy, but we welcome and encourage contributions from the community to help improve its capabilities and stability.

How to Contribute

  1. Discussions: Have a feature request or want to discuss the roadmap? Use GitHub Discussions to share your ideas and engage with the community.

  2. Issues: Found a bug or technical issue? Open a GitHub Issue to report it with details about the problem. Provider-behavior reports are especially valuable — the quirk registry only grows through observation.

  3. Pull Requests: We welcome pull requests! If you'd like to contribute code:

    • Fork the repository
    • Create a new branch for your feature/fix
    • Submit a PR with a clear description of the changes and any related issues
  4. Feedback: Share your experience using the library or suggest improvements.

All contributions, whether they're feature requests, bug reports, or code changes, help make this project better for everyone. Please ensure your contributions adhere to the existing code style and include appropriate tests where applicable.

License

Released under the MIT License.

Documentation

Overview

Package agenthooks lets you author coding-agent hooks once and run them on Claude Code, Cursor, OpenAI Codex, Gemini CLI, OpenCode, and Kimi Code.

A hook program is a normal Go binary: register typed handlers on a Runner and hand control to Main. The library detects the invoking provider, decodes stdin, dispatches, and encodes the response in the provider's dialect — including exit code and stderr discipline. Consumers never see the wire; they see typed events and return typed decisions.

func main() {
	r := agenthooks.New(agenthooks.WithPolicy(agenthooks.Policy{
		Fail:        agenthooks.FailClosed,
		AskFallback: agenthooks.FallbackDeny,
	}))
	r.OnToolPre(func(ctx context.Context, e *agenthooks.ToolPreEvent) (agenthooks.ToolPreDecision, error) {
		if e.Tool.Canonical == agenthooks.ToolShell && isDestructive(e.Tool) {
			return agenthooks.Deny("blocked by policy"), nil
		}
		return agenthooks.NoDecision(), nil
	})
	agenthooks.Main(r)
}

Index

Constants

View Source
const DefaultContinuationCap = 5

DefaultContinuationCap bounds ContinueWith loops on providers without a native cap (Cursor Claude-compat mode ships loop_limit: null).

Variables

View Source
var ErrLossyUpdate = errors.New("agenthooks: updated input removes keys; provider merge is shallow (lossy)")

ErrLossyUpdate is returned when an input rewrite removes keys on a provider whose update mechanism is a shallow merge (Gemini): the removal cannot be expressed, so honoring the rewrite would silently corrupt the tool call.

View Source
var ErrUnsupportedDecision = errors.New("agenthooks: decision unsupported by provider")

ErrUnsupportedDecision is returned (under Policy.Unsupported == Strict) when a handler decision cannot be expressed on the invoking provider.

Functions

func CompileMatcher

func CompileMatcher(p Provider, m ToolMatcher) (expr string, ok bool)

CompileMatcher renders the matcher in the provider's dialect. ok is false when the dialect can't express it (e.g. Cursor has no matchers), in which case config generation matches broadly and the runner filters in-process.

func Logger

func Logger(ctx context.Context) *slog.Logger

Logger returns the runner's logger from a handler context.

func Main

func Main(r *Runner)

Main parses argv/stdin, runs the registered handlers, writes the response, and exits with the dialect-correct code. It also redirects the process's stdout so stray handler prints can't corrupt the wire (Gemini parses stderr as the decision when stdout is empty; Codex rejects unknown JSON).

When argv carries --async (rendered by install for telemetry events on providers without native async hooks, quirk #10), Main hands the payload to a detached copy of this process and exits immediately; the child runs the normal path without the flag. Detaching is process management, so it lives here rather than in the testable Run.

func SynthesizeToolID

func SynthesizeToolID(sessionID, turnID, toolName string, input []byte) string

SynthesizeToolID derives a stable id for providers that omit one: hash(session|turn|tool|input) -> hook_synth_<16 hex>.

Types

type AgentInfo

type AgentInfo struct {
	ID   string
	Type string // provider's subagent type name
}

AgentInfo describes the subagent context, when the event fired inside one.

type AskFallback

type AskFallback int

AskFallback selects the degradation target when Ask is unsupported.

const (
	FallbackNoDecision AskFallback = iota
	FallbackDeny
)

type CanonicalTool

type CanonicalTool string

CanonicalTool classifies native tools into a small cross-provider taxonomy.

const (
	ToolShell     CanonicalTool = "shell"
	ToolFileRead  CanonicalTool = "file.read"
	ToolFileWrite CanonicalTool = "file.write"
	ToolFileEdit  CanonicalTool = "file.edit"
	ToolSearch    CanonicalTool = "search"
	ToolFetch     CanonicalTool = "fetch"
	ToolTask      CanonicalTool = "task"
	ToolMCP       CanonicalTool = "mcp"
	ToolOther     CanonicalTool = "other"
)

func CanonicalToolFor

func CanonicalToolFor(name string) CanonicalTool

CanonicalToolFor classifies a native tool name. MCP names (any dialect) classify as ToolMCP.

type CapSet

type CapSet map[Capability]bool

CapSet is the set of capabilities available on one provider+event pair.

func Capabilities

func Capabilities(p Provider, v Variant, k EventKind) CapSet

Capabilities reports what a decision can express for the given provider/variant/event combination.

func (CapSet) Has

func (s CapSet) Has(c Capability) bool

type Capability

type Capability string

Capability names one thing a decision can express. The matrix below encodes which providers honor it on which events, so degradation is explicit and typed instead of silent.

const (
	CapDeny          Capability = "deny"
	CapAsk           Capability = "ask"
	CapAllow         Capability = "allow"
	CapUpdateInput   Capability = "update-input"
	CapAddContext    Capability = "add-context"
	CapReplaceOutput Capability = "replace-output"
	CapContinueAgent Capability = "continue-agent"
	CapStopAgent     Capability = "stop-agent"
	CapSystemMessage Capability = "system-message"
)

type CompactEvent

type CompactEvent struct {
	Event
	Trigger      string
	Instructions string
}

type DegradeMode

type DegradeMode int

DegradeMode governs unsupported decisions (e.g. Ask on Codex).

const (
	// Degrade maps to the nearest supported intent (Ask->Deny or
	// Ask->NoDecision per AskFallback) and logs the downgrade.
	Degrade DegradeMode = iota
	// Strict treats an unsupported decision as a handler error, which then
	// follows Policy.Fail.
	Strict
)

type DetectionConfidence

type DetectionConfidence string

DetectionConfidence records how the invoking provider was identified.

const (
	DetectionConfig DetectionConfidence = "config" // --provider flag from generated config
	DetectionEnv    DetectionConfidence = "env"    // environment variable sniffing
	DetectionShape  DetectionConfidence = "shape"  // payload shape sniffing
)

type Event

type Event struct {
	Provider   Provider
	Variant    Variant
	NativeName string    // "PreToolUse", "beforeShellExecution", "BeforeTool", ...
	Kind       EventKind // normalized; KindOther when unmapped
	Time       time.Time // library receive time; Gemini also supplies its own

	Session SessionInfo
	Agent   *AgentInfo // non-nil inside a subagent context

	DetectionConfidence DetectionConfidence

	// Backfilled marks an event the provider never sent: some providers skip
	// events in some modes (quirks #30, #31), and the runner synthesizes the
	// miss on the next delivered event, best-effort. Backfilled events are
	// reporting-only — Raw is nil, Can() reports no capabilities, and any
	// decision returned by the handler is discarded.
	Backfilled bool

	// Raw is the verbatim provider payload. Never normalized, never trimmed.
	Raw json.RawMessage
}

Event is the unified envelope. Raw is always the verbatim provider payload; normalization is a projection over it, never a replacement.

func EventOf

func EventOf(typed any) *Event

EventOf returns the embedded envelope of any typed event returned by the decoder, or nil if the value is not an agenthooks event. Consumers that dispatch on the concrete types (rather than registering handlers) use it to reach the shared envelope fields.

func (*Event) Can

func (e *Event) Can(c Capability) bool

Can reports whether the event's provider honors the capability. Backfilled events are reporting-only: the provider never sent them, so nothing can be gated or mutated.

func (*Event) RawField

func (e *Event) RawField(path string) json.RawMessage

RawField resolves a dot-separated path (object keys and array indexes) into the raw payload. It returns nil when the path does not resolve.

type EventKind

type EventKind string

EventKind is the unified, Claude-shaped event taxonomy. The set is open: any native event with no mapping is delivered as KindOther with the native name and raw payload intact.

const (
	KindSessionStart    EventKind = "session.start"
	KindSessionEnd      EventKind = "session.end"
	KindPromptSubmitted EventKind = "prompt.submitted"
	KindToolPre         EventKind = "tool.pre" // gate/rewrite before execution
	KindToolPost        EventKind = "tool.post"
	KindToolError       EventKind = "tool.error"
	KindPermission      EventKind = "permission.request"
	KindStop            EventKind = "agent.stop" // turn finished
	KindSubagentStart   EventKind = "subagent.start"
	KindSubagentStop    EventKind = "subagent.stop"
	KindCompactPre      EventKind = "compact.pre"
	KindCompactPost     EventKind = "compact.post"
	KindNotification    EventKind = "notification"
	KindFileEdited      EventKind = "file.edited"   // post-hoc file-change reports
	KindModelRequest    EventKind = "model.request" // gemini BeforeModel, opencode chat.params
	KindModelResponse   EventKind = "model.response"
	KindOther           EventKind = "other" // any unmapped native event
)

type FailMode

type FailMode int

FailMode governs what a handler error, panic, or timeout means.

const (
	FailOpen   FailMode = iota // handler error/timeout -> NoDecision
	FailClosed                 // handler error/timeout -> Deny (where possible)
)

type FileEditedEvent

type FileEditedEvent struct {
	Event
	Path string
}

type MCPCall

type MCPCall struct {
	Server  string // decoded from mcp__s__t / mcp_s_t / MCP:t + context; "" if undecodable
	Tool    string // tool name as the MCP server knows it
	URL     string // remote transport: payload-borne or config-resolved
	Command string // stdio transport: command plus args, space-joined
	// FromConfig marks URL/Command as resolved from the provider's config
	// files rather than carried by the event payload. Config-resolved
	// transport is best-effort: treat it as advisory, not authoritative.
	FromConfig bool
}

MCPCall carries the decoded MCP tool identity plus the server's transport. Cursor and Gemini MCP events ship url/command in the hook payload; elsewhere the runner resolves them from provider config (quirk #25) and flags the result FromConfig.

func ParseMCPName

func ParseMCPName(name string) *MCPCall

ParseMCPName decodes the three MCP tool-name dialects: mcp__server__tool (Claude/Codex), mcp_server_tool (Gemini, best-effort since "_" is ambiguous), and MCP:tool (Cursor, server unknown). It returns nil when the name is not MCP-shaped.

type ModelEvent

type ModelEvent struct {
	Event
}

ModelEvent covers the experimental model.request/model.response kinds (Gemini BeforeModel/AfterModel, OpenCode chat.params). Observe-only in v1.

type NotificationEvent

type NotificationEvent struct {
	Event
	Message string
}

type Option

type Option func(*Runner)

Option configures a Runner.

func WithDedupDir

func WithDedupDir(dir string) Option

WithDedupDir relocates the on-disk cross-process state: the dedup markers used for Cursor's duplicate tool events (quirk #2), launch-context MCP inventory caches (quirks #26, #32), and the prompt-backfill markers (quirks #30, #31). Without this option, MCP inventories use os.UserCacheDir when available; other state uses os.TempDir.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger replaces the default logger (stderr, warn level).

func WithPolicy

func WithPolicy(p Policy) Option

WithPolicy sets a static policy for every event.

func WithPolicyFunc

func WithPolicyFunc(f PolicyFunc) Option

WithPolicyFunc resolves policy per event (ratchets, per-tool strictness, ...).

func WithoutBackfill

func WithoutBackfill() Option

WithoutBackfill disables synthesizing reporting-only events for provider misses (the prompt.submitted backfill for Kimi/Cursor print modes, quirks #30 and #31).

func WithoutDedup

func WithoutDedup() Option

WithoutDedup disables Cursor duplicate-event suppression.

func WithoutMCPListFallback

func WithoutMCPListFallback() Option

WithoutMCPListFallback disables provider CLI inventory probes (`claude mcp list`, `codex mcp list --json`). Direct config-file resolution stays active; launch overrides that cannot be safely reproduced stay unattributed.

func WithoutMCPResolution

func WithoutMCPResolution() Option

WithoutMCPResolution disables filling MCPCall.URL/Command when the payload carries no transport info — both the config-file fast path (quirk #25) and the `claude mcp list` fallback (quirk #26).

type PermissionEvent

type PermissionEvent struct {
	Event
	Tool ToolCall
}

type Policy

type Policy struct {
	Fail        FailMode
	Unsupported DegradeMode
	AskFallback AskFallback
	// ContinuationCap caps ContinueWith loops; 0 means DefaultContinuationCap.
	ContinuationCap int
	// Timeout bounds handler execution. 0 derives a deadline from the
	// --timeout flag in the generated config (90% of it) or defaultDeadline.
	Timeout time.Duration
}

Policy declares how the runner behaves when a handler fails or asks for something the provider can't do. FailClosed is enforced with the provider's real mechanism per event; on events with no blocking mechanism it downgrades to logging (see Can(CapDeny)).

type PolicyFunc

type PolicyFunc func(*Event) Policy

PolicyFunc resolves policy per event, enabling consumer patterns like a ratchet (fail-open until first success, then fail-closed).

type PromptDecision

type PromptDecision struct {
	// contains filtered or unexported fields
}

PromptDecision gates prompt.submitted events.

func AcceptPrompt

func AcceptPrompt() PromptDecision

func BlockPrompt

func BlockPrompt(reason string) PromptDecision

func (PromptDecision) StopAgent

func (d PromptDecision) StopAgent(reason string) PromptDecision

func (PromptDecision) WithContext

func (d PromptDecision) WithContext(s string) PromptDecision

func (PromptDecision) WithSystemMessage

func (d PromptDecision) WithSystemMessage(s string) PromptDecision

type PromptEvent

type PromptEvent struct {
	Event
	Prompt string
}

type Provider

type Provider string

Provider identifies the coding agent that invoked the hook.

const (
	ProviderClaudeCode Provider = "claude-code" // incl. cowork/desktop/web variants
	ProviderCursor     Provider = "cursor"      // incl. cursor-agent CLI, cloud agents
	ProviderCodex      Provider = "codex"
	ProviderGemini     Provider = "gemini"
	ProviderOpenCode   Provider = "opencode"
	ProviderKimi       Provider = "kimi-code" // Kimi Code CLI ("kimi" accepted as a flag alias)
)

type Quirk

type Quirk struct {
	ID         int
	Provider   Provider
	Versions   string // affected version range, free-form ("all", ">=2.0.64", ...)
	Event      EventKind
	Capability Capability // "" when not capability-scoped
	Behavior   string
	Mitigation string
	Reference  string // upstream issue/doc pointer
}

Quirk is one entry in the machine-readable registry of provider glue this library hides. The registry doubles as the conformance-test plan: every quirk gets a fixture. Docs markers (⚠) are generated from this table.

func Quirks

func Quirks() []Quirk

Quirks returns the full registry, ordered by ID.

func QuirksFor

func QuirksFor(p Provider) []Quirk

QuirksFor filters the registry by provider.

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner holds registered handlers and policy. One handler per unified event kind; OnAny is additive (observe-only) and runs regardless. Typed handlers gate/mutate; OnAny never does.

func New

func New(opts ...Option) *Runner

New builds a Runner. Default policy: FailOpen, Degrade, FallbackNoDecision.

func (*Runner) OnAny

func (r *Runner) OnAny(fn func(context.Context, *Event) error)

OnAny receives EVERY event, mapped or not, with the raw payload — the fidelity escape hatch. Observe-only: errors are logged, never gate.

func (*Runner) OnCompactPost

func (r *Runner) OnCompactPost(fn func(context.Context, *CompactEvent) error)

func (*Runner) OnCompactPre

func (r *Runner) OnCompactPre(fn func(context.Context, *CompactEvent) error)

func (*Runner) OnFileEdited

func (r *Runner) OnFileEdited(fn func(context.Context, *FileEditedEvent) error)

func (*Runner) OnModelRequest

func (r *Runner) OnModelRequest(fn func(context.Context, *ModelEvent) error)

func (*Runner) OnModelResponse

func (r *Runner) OnModelResponse(fn func(context.Context, *ModelEvent) error)

func (*Runner) OnNotification

func (r *Runner) OnNotification(fn func(context.Context, *NotificationEvent) error)

func (*Runner) OnOther

func (r *Runner) OnOther(nativeName string, fn func(context.Context, *Event) error)

OnOther receives events whose native name matches, typically ones with no unified mapping (Kind == KindOther). Observe-only.

func (*Runner) OnPermission

func (r *Runner) OnPermission(fn func(context.Context, *PermissionEvent) (ToolPreDecision, error))

func (*Runner) OnPromptSubmitted

func (r *Runner) OnPromptSubmitted(fn func(context.Context, *PromptEvent) (PromptDecision, error))

func (*Runner) OnSessionEnd

func (r *Runner) OnSessionEnd(fn func(context.Context, *SessionEndEvent) error)

func (*Runner) OnSessionStart

func (r *Runner) OnSessionStart(fn func(context.Context, *SessionStartEvent) (SessionStartDecision, error))

func (*Runner) OnStop

func (r *Runner) OnStop(fn func(context.Context, *StopEvent) (StopDecision, error))

func (*Runner) OnSubagentStart

func (r *Runner) OnSubagentStart(fn func(context.Context, *SubagentStartEvent) error)

func (*Runner) OnSubagentStop

func (r *Runner) OnSubagentStop(fn func(context.Context, *StopEvent) (StopDecision, error))

func (*Runner) OnToolError

func (r *Runner) OnToolError(fn func(context.Context, *ToolPostEvent) (ToolPostDecision, error))

func (*Runner) OnToolPost

func (r *Runner) OnToolPost(fn func(context.Context, *ToolPostEvent) (ToolPostDecision, error))

func (*Runner) OnToolPre

func (r *Runner) OnToolPre(fn func(context.Context, *ToolPreEvent) (ToolPreDecision, error))

func (*Runner) Run

func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int

Run is the testable core of Main: explicit streams, returns the exit code.

type SessionEndEvent

type SessionEndEvent struct {
	Event
	Reason string
}

type SessionInfo

type SessionInfo struct {
	ID             string // claude/codex/gemini session_id; cursor conversation_id
	TurnID         string // codex turn_id, cursor generation_id, claude prompt_id ("" if absent)
	CWD            string
	WorkspaceRoots []string // cursor multi-root; others: [CWD] or project dir
	TranscriptPath string   // "" if unavailable; format is provider-specific (see transcript pkg)
	Model          string   // "" if not reported
	PermissionMode string   // claude/codex permission_mode; "" elsewhere
	UserEmail      string   // cursor user_email; "" elsewhere
}

SessionInfo carries the normalized session identity fields.

type SessionStartDecision

type SessionStartDecision struct {
	// contains filtered or unexported fields
}

SessionStartDecision responds to session.start.

func ContinueSession

func ContinueSession() SessionStartDecision

func (SessionStartDecision) WithContext

func (SessionStartDecision) WithSystemMessage

func (d SessionStartDecision) WithSystemMessage(s string) SessionStartDecision

type SessionStartEvent

type SessionStartEvent struct {
	Event
	Source string // "startup", "resume", ... (provider-specific vocabulary)
}

type StopDecision

type StopDecision struct {
	// contains filtered or unexported fields
}

StopDecision responds to agent.stop / subagent.stop.

func ContinueWith

func ContinueWith(instruction string) StopDecision

ContinueWith keeps the agent working: claude decision:block+reason, cursor followup_message, codex continuation prompt, gemini retry. The runner enforces Policy.ContinuationCap so consumers can't build infinite loops on providers without native caps.

func Finish

func Finish() StopDecision

Finish lets the agent stop.

func (StopDecision) WithSystemMessage

func (d StopDecision) WithSystemMessage(s string) StopDecision

type StopEvent

type StopEvent struct {
	Event
	// PreviouslyContinued surfaces stop_hook_active / loop_count uniformly.
	PreviouslyContinued bool
	// LoopCount is the provider-reported continuation count when available,
	// or 1 when only a boolean guard (stop_hook_active) exists.
	LoopCount int
	// FinalMessage is the last assistant message of the turn when the provider
	// includes it on the stop event (Claude/Codex last_assistant_message).
	FinalMessage string
	// Usage carries end-of-turn token/cost totals when the provider reports
	// them on stop (Cursor), nil otherwise.
	Usage *Usage
}

type SubagentStartEvent

type SubagentStartEvent struct {
	Event
}

type ToolCall

type ToolCall struct {
	ID          string        // native id, or synthesized (see Synthesized)
	Synthesized bool          // true when the provider omitted an id (Cursor MCP cases)
	Name        string        // native tool name, verbatim
	Canonical   CanonicalTool // cross-provider classification
	MCP         *MCPCall      // non-nil when the call targets an MCP tool

	// Input is ALWAYS a JSON object (the library un-stringifies Cursor's
	// string form and wraps non-object inputs as {"value": ...}).
	Input json.RawMessage
	// RawInput is the input exactly as the provider sent it (nil when the
	// provider sent none and Input was constructed from other fields).
	RawInput json.RawMessage
}

ToolCall is the normalized view of a tool invocation.

type ToolMatcher

type ToolMatcher struct {
	Names     []string        // exact native tool names
	Canonical []CanonicalTool // canonical classes
	MCP       []string        // "server/*" or "server/tool" globs
}

ToolMatcher is the unified tool matcher used both by config generation (compiled to each provider's matcher dialect where expressible) and by the runner's in-process filter (--filter flag) where not.

func ParseToolMatcher

func ParseToolMatcher(s string) (ToolMatcher, error)

ParseToolMatcher parses the --filter flag format produced by Encode.

func (ToolMatcher) Encode

func (m ToolMatcher) Encode() string

Encode serializes the matcher for the --filter flag baked into generated configs whose provider dialect can't express it.

func (ToolMatcher) IsEmpty

func (m ToolMatcher) IsEmpty() bool

IsEmpty reports whether the matcher matches everything.

func (ToolMatcher) Matches

func (m ToolMatcher) Matches(t ToolCall) bool

Matches reports whether the tool call satisfies the matcher. An empty matcher matches every tool.

type ToolPostDecision

type ToolPostDecision struct {
	// contains filtered or unexported fields
}

ToolPostDecision responds to tool.post / tool.error.

func FlagOutput

func FlagOutput(reason string) ToolPostDecision

FlagOutput sends feedback about the tool output to the model.

func Observed

func Observed() ToolPostDecision

Observed acknowledges the event with no opinion.

func ReplaceOutput

func ReplaceOutput(v any) ToolPostDecision

ReplaceOutput substitutes the tool output where supported: claude updatedToolOutput, cursor updated_mcp_tool_output (MCP only), gemini tool_output, opencode output mutation.

func (ToolPostDecision) StopAgent

func (d ToolPostDecision) StopAgent(reason string) ToolPostDecision

func (ToolPostDecision) WithContext

func (d ToolPostDecision) WithContext(s string) ToolPostDecision

func (ToolPostDecision) WithSystemMessage

func (d ToolPostDecision) WithSystemMessage(s string) ToolPostDecision

type ToolPostEvent

type ToolPostEvent struct {
	Event
	Tool   ToolCall
	Output json.RawMessage // provider's tool_response/output, verbatim
	Failed bool            // true on tool.error and error-carrying tool.post
	Error  string
	// DurationMS is the tool execution time in milliseconds when the provider
	// reports one (Cursor duration/duration_ms), nil otherwise.
	DurationMS *float64
}

type ToolPreDecision

type ToolPreDecision struct {
	// contains filtered or unexported fields
}

ToolPreDecision gates tool.pre and permission.request events.

func Allow

func Allow() ToolPreDecision

Allow skips the permission prompt where supported. It never loosens policy: provider-side deny rules still apply.

func AskUser

func AskUser(reason string) ToolPreDecision

AskUser forces a confirmation prompt where the provider supports one. Behavior on providers without ask is governed by Policy.AskFallback.

func Deny

func Deny(reason string) ToolPreDecision

Deny blocks the tool call with feedback for the model.

func NoDecision

func NoDecision() ToolPreDecision

NoDecision defers to the provider's normal permission flow. It is NEVER a forced allow: the codecs emit each provider's correct "no opinion" form.

func (ToolPreDecision) StopAgent

func (d ToolPreDecision) StopAgent(reason string) ToolPreDecision

StopAgent requests continue:false where supported.

func (ToolPreDecision) WithContext

func (d ToolPreDecision) WithContext(s string) ToolPreDecision

WithContext injects context for the model where supported.

func (ToolPreDecision) WithSystemMessage

func (d ToolPreDecision) WithSystemMessage(s string) ToolPreDecision

WithSystemMessage attaches a user-facing note where supported.

func (ToolPreDecision) WithUpdatedInput

func (d ToolPreDecision) WithUpdatedInput(v any) ToolPreDecision

WithUpdatedInput rewrites the tool arguments before execution.

type ToolPreEvent

type ToolPreEvent struct {
	Event
	Tool ToolCall
}

type Usage

type Usage struct {
	InputTokens      *int
	OutputTokens     *int
	CacheReadTokens  *int
	CacheWriteTokens *int
	Cost             *float64
	LoopCount        *int
	Status           string
}

Usage carries the token and cost totals a provider reports at the end of a turn. Pointer fields distinguish "reported as zero" from "not reported".

type Variant

type Variant string

Variant refines Provider where runtime behavior genuinely differs.

const (
	VariantUnknown Variant = ""
	VariantCLI     Variant = "cli"
	VariantIDE     Variant = "ide"
	VariantCloud   Variant = "cloud"
	VariantCowork  Variant = "cowork"
	VariantRemote  Variant = "remote"
)

Directories

Path Synopsis
Package agenthookstest provides the golden fixture corpus and a fake-provider harness so hook binaries built on agenthooks can be integration-tested in CI without the actual agents (DESIGN.md §10).
Package agenthookstest provides the golden fixture corpus and a fake-provider harness so hook binaries built on agenthooks can be integration-tested in CI without the actual agents (DESIGN.md §10).
Package e2e exercises agenthooks against real, locally installed coding agents: it renders hook configuration with the install package, points it at a recorder binary built from testdata/recorder, drives one headless agent turn, and asserts on the events that actually arrived and on decision enforcement (a deny must block the tool).
Package e2e exercises agenthooks against real, locally installed coding agents: it renders hook configuration with the install package, points it at a recorder binary built from testdata/recorder, drives one headless agent turn, and asserts on the events that actually arrived and on decision enforcement (a deny must block the tool).
examples
secretguard command
Command secretguard is an example agenthooks consumer: it scans every tool call's input for credential-shaped strings before execution.
Command secretguard is an example agenthooks consumer: it scans every tool call's input for credential-shaped strings before execution.
Package install renders and installs provider hook configurations from a single Go Manifest: correct hooks.json / settings.json / config.toml / plugin scaffolding per provider, with the per-provider timing/async/ fail-mode workarounds baked in (DESIGN.md §7).
Package install renders and installs provider hook configurations from a single Go Manifest: correct hooks.json / settings.json / config.toml / plugin scaffolding per provider, with the per-provider timing/async/ fail-mode workarounds baked in (DESIGN.md §7).
internal
jsonx
Package jsonx provides JSON decoding with unknown-field capture.
Package jsonx provides JSON decoding with unknown-field capture.
transcriptio
Package transcriptio is the transcript-parsing core shared by the public transcript package and the runner's prompt backfill (the root package cannot import transcript without a cycle).
Package transcriptio is the transcript-parsing core shared by the public transcript package and the runner's prompt backfill (the root package cannot import transcript without a cycle).
provider
claudecode
Package claudecode provides complete typed views over native Claude Code hook payloads.
Package claudecode provides complete typed views over native Claude Code hook payloads.
codex
Package codex provides typed views over native Codex hook payloads.
Package codex provides typed views over native Codex hook payloads.
cursor
Package cursor provides typed views over native Cursor hook payloads (IDE, cursor-agent CLI, and cloud agents).
Package cursor provides typed views over native Cursor hook payloads (IDE, cursor-agent CLI, and cloud agents).
gemini
Package gemini provides typed views over native Gemini CLI hook payloads.
Package gemini provides typed views over native Gemini CLI hook payloads.
kimicode
Package kimicode provides complete typed views over native Kimi Code CLI hook payloads.
Package kimicode provides complete typed views over native Kimi Code CLI hook payloads.
opencode
Package opencode provides typed views over the NDJSON frames the generated agenthooks OpenCode shim plugin proxies from OpenCode's in-process plugin hooks (DESIGN.md §8).
Package opencode provides typed views over the NDJSON frames the generated agenthooks OpenCode shim plugin proxies from OpenCode's in-process plugin hooks (DESIGN.md §8).
Package transcript provides best-effort JSONL readers for provider transcript files (Event.Session.TranscriptPath).
Package transcript provides best-effort JSONL readers for provider transcript files (Event.Session.TranscriptPath).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL