A Go library for authoring coding-agent hooks once and running them everywhere: Claude Code, Cursor (IDE + CLI + cloud), OpenAI Codex, Gemini CLI, OpenCode, Kimi Code, and GitHub Copilot CLI.
The core promise: one clear interface, zero data-fidelity loss. The library owns the per-provider glue, hacks, and workarounds so consumers don't have to.
Research verified against provider docs, source, and changelogs as of 2026-07-02 (Claude Code 2.1.x, Codex hooks post-v0.124, Cursor 2.4 / CLI May-2026+, Gemini CLI v0.26+ GA, OpenCode 1.17.x).
The single most important finding: Claude Code's hook contract is the de-facto industry standard.
| Provider | Relationship to Claude contract |
|---|---|
| Claude Code | The reference. 30 events, stdin snake_case JSON in, camelCase JSON out, exit 0/2/other, hookSpecificOutput, matchers, settings.json / plugin hooks/hooks.json. |
| Codex | Deliberate Claude dialect. Same event names, same hookSpecificOutput/permissionDecision shapes, even exports CLAUDE_PLUGIN_ROOT/CLAUDE_PLUGIN_DATA for compat. 10 events. Machine-readable JSON schemas published in-repo. |
| Gemini CLI | Claude-inspired with renames (PreToolUse→BeforeTool, Stop→AfterAgent), same base input fields plus timestamp, top-level decision instead of hookSpecificOutput.permissionDecision. Adds model-level hooks Claude lacks. |
| Cursor | Own dialect (camelCase event names, per-event output schemas, permission/user_message/agent_message, followup_message) plus a Claude-compat layer that reads .claude/settings.json and accepts Claude response shapes. |
| OpenCode | The outlier: in-process JS/TS plugins mutating shared objects, no spawned-process protocol at all. Requires a shim (§8). |
(Kimi Code, added after the initial research, ships another Claude-shaped dialect with renamed keys and a narrower response surface — see quirk registry entries #21–24.)
(GitHub Copilot CLI, also added later, is a CLI-only camelCase dialect with
per-event output schemas — permissionDecision, behavior, decision,
additionalContext — and one structural oddity no other provider has: most
payloads do not carry their own event name, so the codec reconstructs it from
the payload shape. The shapes are disjoint, so the reconstruction is exact.
Argument shape is split too — toolArgs is a JSON-encoded string on
pre/postToolUse, toolInput a plain object on permissionRequest — and no
tool-call id ships at all, so ids are synthesized. preToolUse is fail-closed
on any non-zero exit, not just exit 2, so the codec never signals through
the exit code: it always exits 0 and encodes the verdict on stdout, which stops
a crashed hook from denying every tool call. prompt.submitted gets an empty
capability set because Copilot discards command-hook output for
userPromptSubmitted.)
Consequence: the unified contract should be Claude-shaped semantics with typed extensions, not a lowest-common-denominator invention. Three of five providers natively converge on it; Cursor half-converges; only OpenCode needs a real bridge.
Secondary findings that shape the design (full quirk registry in §9):
- Every provider disagrees on something mechanical: timeout units (s vs ms),
MCP tool naming (
mcp__srv__toolvsmcp_srv_toolvsMCP:prefix),tool_inputtype (object vs JSON-stringified string), empty-stdout meaning (Claude: no decision; Codex: allow; Gemini: stderr gets parsed instead), fail-open vs fail-closed defaults, exit-code semantics (Gemini blocks on any non-zero except 1, despite its docs). - Providers ship bugs and drift: Cursor broke camelCase output fields in
2.0.64, fires hooks differently across IDE/CLI/cloud, double-fires MCP tools;
Claude cowork skips async
Stophooks; Gemini'sSessionStarthas an open not-firing regression. The library must be a place where these get encoded once, tested, and versioned.
- Fidelity first. The raw provider payload is always retained verbatim and reachable. Normalization is a projection over the raw data, never a replacement for it. Unknown fields are never dropped.
- Claude-shaped canon. Unified names and semantics follow Claude Code where a mapping exists; provider deltas are explicit, typed extensions.
- Open taxonomy. The unified event set is not closed. Any native event
with no mapping still reaches the consumer (as
Kind == KindOtherwith the native name and raw payload). New provider events degrade gracefully, they don't error. - Explicit capability degradation. When a consumer asks for something a
provider can't do (e.g.
Askon Codex, deny on a fire-and-forget Cursor event), the library never silently discards it — behavior is governed by a declared policy (§6). - The library owns the wire, the consumer owns the logic. Consumers never see exit codes, stdout JSON dialects, stderr discipline, or matcher regex flavors. They see typed events and return typed decisions.
- Config is generated, not hand-written. One Go
Manifestproduces correcthooks.json/settings.json/config.toml/ plugin scaffolding per provider, with the per-provider timing/async/fail-mode workarounds baked in (§7).
Non-goals for v1 (§11): auth/login flows, HTTP transport to a decision server, transcript capture pipelines. These are consumer concerns layered on top of this library, not inside it.
Module: github.com/speakeasy-api/agenthooks (root package agenthooks).
A hook program is a normal Go binary. The library detects the invoking provider (from generated-config argv, with env/shape sniffing as fallback), decodes stdin, dispatches to the registered handler, and encodes the response in the provider's dialect — including exit code and stderr discipline.
func main() {
r := agenthooks.New(
agenthooks.WithPolicy(agenthooks.Policy{
Fail: agenthooks.FailClosed, // what a handler error/timeout means
AskFallback: agenthooks.FallbackDeny, // when Ask is unsupported
}),
)
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
})
r.OnPromptSubmitted(func(ctx context.Context, e *agenthooks.PromptEvent) (agenthooks.PromptDecision, error) {
return agenthooks.AcceptPrompt().WithContext("today is " + today()), nil
})
// Fidelity escape hatch: receives EVERY event, mapped or not, with raw payload.
r.OnAny(func(ctx context.Context, e *agenthooks.Event) error {
return telemetry.Send(ctx, e.Provider, e.NativeName, e.Raw)
})
agenthooks.Main(r) // parses argv/stdin, runs, writes response, sets exit code
}Notes:
- Registration per unified event kind is variadic and stacks: handler stages
run in order and the first conclusive decision wins (§3.5).
OnAnyis additive (observe-only) and runs regardless. Typed handlers gate/mutate;OnAnynever does. Mainnever lets handler panics/errors leak as garbage on stdout. Outcome on error followsPolicy.Failand the provider's actual blocking mechanism (exit 2, deny body,failClosedflag — see §6).- Logging discipline is enforced: the runner redirects the process's
stdout usage by handlers (and offers
agenthooks.Logger(ctx)) to a state file / stderr only where stderr is safe. This exists because Gemini parses stderr as the decision when stdout is empty, and Codex rejects unknown JSON on stdout. The runner always emits an explicit well-formed response.
type Provider string
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"
)
// Variant refines Provider where runtime behavior genuinely differs.
type Variant string // e.g. "cowork", "cli", "ide", "cloud", "" (unknown)
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
// Backfilled marks a synthesized event for a provider miss (e.g. print
// modes that skip prompt hooks): reporting-only, nil Raw, no capabilities.
Backfilled bool
// Raw is the verbatim provider payload. Never normalized, never trimmed.
Raw json.RawMessage
// Ext carries embedder-defined extension data. The library never reads,
// writes, or propagates it; applications that construct typed events
// themselves stamp app-specific context here for their own handlers.
Ext map[string]any
}
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
}
type AgentInfo struct {
ID string
Type string // provider's subagent type name
}Provider-specific fields that don't generalize are reached two ways, both lossless:
// Typed views (generated from provider schemas; nil if wrong provider/event):
cc, ok := claudecode.PreToolUse(e) // *claudecode.PreToolUseInput
cx, ok := codex.PreToolUse(e)
cu, ok := cursor.BeforeShellExecution(e)
// Or generic:
v := e.RawField("stop_hook_active") // gjson-style path into RawThe provider/* packages ship complete typed structs for every native event
and every native field, kept in sync with upstream schemas (Codex publishes
JSON Schema; Claude/Gemini/Cursor structs are maintained against docs/source
with golden fixtures, §10). The unified layer is convenience; the typed native
layer is the fidelity guarantee.
type EventKind string
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
)Typed event structs embed Event and add normalized fields, e.g.:
type ToolPreEvent struct {
Event
Tool ToolCall
}
type ToolCall struct {
ID string // native id, or synthesized (see Synthesized)
Synthesized bool // true when provider omitted an id (Cursor MCC/MCP cases)
Name string // native tool name, verbatim
Canonical CanonicalTool // ToolShell | ToolFileRead | ToolFileWrite | ToolFileEdit |
// ToolSearch | ToolFetch | ToolTask | ToolMCP | ToolOther
MCP *MCPCall // non-nil when the call targets an MCP tool
Input json.RawMessage // ALWAYS a JSON object (library un-stringifies Cursor's string form)
RawInput json.RawMessage // the input exactly as the provider sent it
}
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 // cursor/gemini transport info when provided
Command string
}ToolCall.ID synthesis (hash(session|turn|tool|input) →
hook_synth_<16 hex>) is deterministic so pre/post records correlate on
providers that omit ids.
| Unified | Claude Code | Codex | Cursor | Gemini | OpenCode (shim) |
|---|---|---|---|---|---|
| session.start | SessionStart | SessionStart | sessionStart | SessionStart | plugin init + session.created |
| session.end | SessionEnd | — | sessionEnd | SessionEnd | server.instance.disposed (best-effort) |
| prompt.submitted | UserPromptSubmit | UserPromptSubmit | beforeSubmitPrompt | BeforeAgent | chat.message |
| tool.pre | PreToolUse | PreToolUse | preToolUse + beforeShellExecution + beforeMCPExecution + beforeReadFile (deduped, §9) | BeforeTool | tool.execute.before |
| tool.post | PostToolUse | PostToolUse | postToolUse + afterShellExecution + afterMCPExecution + afterFileEdit (deduped) | AfterTool | tool.execute.after |
| tool.error | PostToolUseFailure | — | postToolUseFailure | — (AfterTool w/ error field) | tool.execute.after (error state) |
| permission.request | PermissionRequest | PermissionRequest | — | — (ask decision on BeforeTool only) |
permission.asked event + HTTP reply (§8) |
| agent.stop | Stop | Stop | stop | AfterAgent | session.idle |
| subagent.start | SubagentStart | SubagentStart | subagentStart | — | session.created (child) |
| subagent.stop | SubagentStop | SubagentStop | subagentStop | — | session.idle (child) |
| compact.pre | PreCompact | PreCompact | preCompact | PreCompress | experimental.session.compacting |
| compact.post | PostCompact | PostCompact | — | — | session.compacted |
| notification | Notification | — | — | Notification | tui.toast.show (observe) |
| file.edited | FileChanged | — | afterFileEdit / afterTabFileEdit | — | file.edited |
| model.request | — | — | — | BeforeModel / BeforeToolSelection | chat.params / chat.headers |
| model.response | MessageDisplay (approx.) | — | afterAgentResponse / afterAgentThought | AfterModel | experimental.text.complete |
| other | remaining ~15 events (Setup, TaskCreated, Elicitation, Worktree*, …) | — | remaining (workspaceOpen, Tab events, …) | — | remaining bus events |
Everything in the last row is still fully deliverable via OnAny /
OnOther(nativeName, …) with typed native structs — "unmapped" never means
"unavailable".
Handler registration is a composable event router. The composable unit is the
handler func type itself (the http.Handler/middleware idiom): combinators
take handlers and return handlers, so leaves and compositions register
identically.
Registration is variadic and stacks. OnToolPre(hs ...) and its siblings
accept any number of handlers, and repeated calls append. Stages run in
registration order; a neutral decision (the zero value —
Kind() == DecisionNoDecision) falls through, and the first conclusive
decision wins. A lone handler behaves exactly as it did before registration
stacked: when every stage stays neutral the neutrals merge (contexts append
in order, StopAgent sticky) instead of being dropped, so an enriched
neutral (NoDecision().WithContext(…)) still reaches the wire.
Combinators are generic over the event/decision pairs — one implementation covers all five gating kinds and call sites never write a type parameter — and observe the closure property: each returns the same func type it takes, so compositions nest and register like leaves.
Any(hs…)— handlers run in order, the first conclusive decision wins and short-circuits the rest; order is priority. Identical semantics to stacked registration — one rule everywhere. A handler error aborts immediately.All(hs…)— every handler runs (no short-circuit: all findings and side effects are recorded), then the results merge: the most restrictive kind wins (deny > ask > allow > neutral; continue > finish; replace-output > flag-output > observed; ties go to the earliest),Contextappends from all decisions in order, the winner's other fields are taken wholesale, andStopAgentis sticky. Errors: every handler still runs, the errors are joined (errors.Join), and any error aborts the combinator.When(m, h)— guard combinator:hruns only when the event carries a tool call the matcher matches; otherwise the stage is neutral. Events without a tool call never match.
Matchers. Matcher is a one-method interface (Matches(ToolCall) bool),
so custom matchers (e.g. CEL-backed) plug in without library dependencies.
The shipped constructors — MatchTools(…), MatchMCP(…),
MatchCanonical(…) — wrap the existing ToolMatcher (§7). Guarding lives in
When, keeping the API to one concept: handlers composing into handlers.
Middleware. Use(i Interceptor) wraps the typed-handler pipeline,
outermost first. An interceptor receives the typed event and the rest of the
pipeline as next (Next func(ctx, typed any) (Decision, error)); it may
transform the normalized projection in place (Tool.Input, prompt text —
Raw/RawInput stay verbatim per §5), short-circuit by returning without
calling next, or post-process next's decision. Interceptors call next at most
once. OnAny/OnOther observers run before the middleware chain and never
gate; an exhausted pipeline is NoDecision.
Decision outcomes. No separate outcome type: the winning decision is
itself readable. DecisionKind is the exported outcome enum
(DecisionNoDecision, DecisionDeny, DecisionAsk, … — append-only int
values, String() for logs), and every decision type carries read accessors:
Kind(), Reason(), SystemMessage(), Context(), Blocks() — the
classification predicate, true exactly for the kinds whose intent is "the
action is prevented" (deny, block-prompt; an ask defers to a human and is
not blocking), so boundaries branch on it instead of enumerating kinds and
pick up future blocking kinds automatically — plus kind-specific
Instruction() (StopDecision), UpdatedInput() (ToolPreDecision), and
ReplacedOutput() (ToolPostDecision). Decision is the sealed read-only
interface all five types satisfy; consumers type-assert to the concrete type
when they need kind-specific fields.
Runner.Decide(ctx, typed) (Decision, error) runs the router pipeline —
observers, middleware, typed handlers — and returns the winning decision with
no wire encoding, no capability degradation (§4.1 is an edge/wire concern:
degrading an ask server-side would collapse it before the caller's boundary
can render it), and no MCP transport resolution. Stage errors (panics
included, converted) return as errors; the caller owns failure semantics. A
neutral outcome is the zero decision. Run/Main remain the edge path: the
same pipeline slots in where single-handler dispatch used to be, and
Policy-driven failure handling, capability degradation, and wire encoding
continue unchanged after it.
Introspection. Walk(fn func(StageInfo) error) visits the registered
top-level stages in dispatch order — OnAny observers, OnOther observers,
middleware (outermost first), then typed handlers grouped by event kind —
with StageInfo{Kind, Type, Name, Pos}. Names are the reflected function
names — good for named funcs and method values; anonymous closures report
their compiler-assigned closure names. Known trade-off: combinator internals
are opaque to Walk — bare funcs carry no metadata — and a richer
introspectable interface can be layered on later without breaking the
func-type API.
Each gating event kind has a decision type with constructors. Decisions carry intent; the provider codec translates intent into that provider's mechanism.
// tool.pre / permission.request
func NoDecision() ToolPreDecision // defer to the provider's normal flow (NEVER a forced allow)
func Allow() ToolPreDecision // skip the permission prompt where supported
func Deny(reason string) ToolPreDecision
func AskUser(reason string) ToolPreDecision // force a confirmation prompt
func (d ToolPreDecision) WithUpdatedInput(v any) ToolPreDecision // rewrite tool args
func (d ToolPreDecision) WithContext(s string) ToolPreDecision // inject context for the model
// prompt.submitted
func AcceptPrompt() PromptDecision
func BlockPrompt(reason string) PromptDecision
func (d PromptDecision) WithContext(s string) PromptDecision
// agent.stop / subagent.stop
func Finish() StopDecision
func ContinueWith(instruction string) StopDecision // claude decision:block+reason, cursor followup_message, codex continuation prompt, gemini retry
// tool.post
func Observed() ToolPostDecision
func FlagOutput(reason string) ToolPostDecision // feedback shown to the model
func ReplaceOutput(v any) ToolPostDecision // claude updatedToolOutput, cursor updated_mcp_tool_output (MCP only), gemini reason-replace
func (d ToolPostDecision) WithContext(s string) ToolPostDecision
// universal modifiers on all decisions
func (d T) WithSystemMessage(s string) T // user-facing note where supported
func (d T) StopAgent(reason string) T // continue:false where supported
// blocking decisions (ToolPreDecision, PromptDecision)
func (d T) WithBlockReason(candidates ...string) T // user-facing block message: first non-empty candidate winsImportant semantics the constructors encode:
NoDecision≠Allow. An empty-body response must let the provider's own permission flow run, not force-allow. The codecs emit the correct "no opinion" form per provider ({}on Claude/Cursor, empty stdout on Codex,{}on Gemini to avoid stderr-parsing).Allownever loosens. On Claude, hook-allow still respects deny rules; the library documents (and tests) thatAllowmeans "skip the ask", not "bypass policy" — matching every provider's actual behavior.- Loop-guard awareness.
StopEvent.PreviouslyContinuedsurfacesstop_hook_active/loop_countuniformly;ContinueWithrefuses to exceed a configurable continuation cap so consumers can't accidentally build infinite loops on providers without native caps (Cursor Claude-compat mode hasloop_limit: null).
type Capability string
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"
)
func Capabilities(p Provider, v Variant, k EventKind) CapSet
func (e *Event) Can(c Capability) boolSelected divergences the matrix encodes (from research):
| Capability | Divergence |
|---|---|
| Ask on tool.pre | Claude ✓; Gemini ✓ (undocumented); Cursor: enforced only on shell/MCP events, ignored on preToolUse, treated as deny on subagentStart; Codex ✗ (fails the hook run) |
| UpdateInput | Claude: full replace; Codex: replace, allow-only; Cursor: updated_input on preToolUse only; Gemini: shallow merge (key removal impossible — library surfaces ErrLossyUpdate if the rewrite removes keys); OpenCode: mutate args ✓ |
| Deny on file reads | Cursor beforeReadFile is allow/deny only (no ask) |
| StopAgent | Claude/Gemini/Codex continue:false; Cursor ✗ (except via deny paths) |
| tool.error | Codex folds failures into PostToolUse; Gemini reports via tool_response.error |
When a handler returns an unsupported decision, Policy decides:
Degrade (map to the nearest supported intent: Ask→Deny or Ask→NoDecision per
AskFallback; log it) or Strict (treat as handler error → Policy.Fail).
type FailMode int
const (
FailOpen FailMode = iota // handler error/timeout → NoDecision
FailClosed // handler error/timeout → Deny (where possible)
)FailClosed is enforced with the provider's real mechanism, which the library
knows per event: exit 2 (Claude/Codex/Cursor), deny JSON body (Cursor stdout),
failClosed: true in generated Cursor config, deny decision (Gemini), thrown
error (OpenCode shim). On events with no blocking mechanism, FailClosed
downgrades to logging (and says so via Can(CapDeny)), because pretending to
block on a fire-and-forget event is worse than being honest.
Consumers who need a ratchet (fail-open until first success, then
fail-closed) implement it in the handler; the library provides the primitives
(Policy is resolvable per event: PolicyFunc func(*Event) Policy).
Event.Rawis byte-identical to what the provider sent (post transport framing only — e.g. argv-decoding for legacy Cursor CLI / Codex notify).- Native typed structs (
provider/*) decode with unknown-field capture: unrecognized JSON keys land in anExtra map[string]json.RawMessageon every struct, never dropped. - Normalization documents its losses. Where a projection is lossy (Gemini's
additionalContextHTML-escaping, Gemini shallow-merge updates, Cursor stringifiedtool_inputre-parsing,MessageDisplay≈model.response), the typed API exposes both the normalized and native forms and the docs carry a ⚠ marker generated from the quirk registry (§9). - Round-trip property: for every fixture in the corpus,
decode(payload) → encode(NoDecision)produces the provider-correct no-op, andRawequals the input.
One binary, several invocation modes, selected by argv the generated configs control:
mybinary agenthooks run --provider=claude-code # process-per-event, stdin JSON (claude, codex, gemini, cursor)
mybinary agenthooks run --provider=cursor --argv-payload # legacy cursor-agent CLI (<2026-05-20): payload in argv
mybinary agenthooks notify --provider=codex # legacy codex notify: kebab-case JSON in argv[1]
mybinary agenthooks serve --provider=opencode # long-lived daemon for the OpenCode shim (§8)
(Consumers embed this by calling agenthooks.Main(r); the subcommand surface
is provided by the library so any consumer binary gets it for free. A
standalone agenthooks CLI that loads handlers is explicitly out of scope —
this is a library.)
Runtime responsibilities per mode:
- stdin mode: read payload, enforce a deadline slightly under the provider-configured timeout (so we always answer rather than get killed mid-write), decode → dispatch → encode, exit with the dialect-correct code.
- stderr/stdout discipline as in §3.1.
- Provider detection: primary =
--providerflag baked into generated config; fallback = env sniffing (CLAUDE_PLUGIN_ROOT/CLAUDE_PROJECT_DIR,CURSOR_VERSION,GEMINI_CWD,CODEX_HOME) then payload-shape sniffing (hook_event_namecasing,conversation_idpresence). Detection result is on the event (Provider,Variant, plusDetectionConfidencefor observability). Note Codex/Cursor deliberately exportCLAUDE_*compat vars, so env sniffing alone is insufficient — flag-first is a hard rule. - Variant detection: cowork = cmux
local_<rid>.jsonadjacency /CLAUDE_PROJECT_DIRshape; remote =CLAUDE_CODE_REMOTE; cursor cloud/CLI = payload capability probing.
pkg install
type Manifest struct {
Command []string // how to invoke the consumer binary (abs path or PATH lookup)
Hooks []HookSpec
Identity Identity // plugin name/version/description for plugin-based installs
}
type HookSpec struct {
Kind agenthooks.EventKind
Tools ToolMatcher // unified matcher (names, canonical classes, MCP globs)
Blocking bool // decision path vs telemetry path
Timeout time.Duration
}
func Render(m Manifest, target Target) (fs.FS, error) // Target = provider (+ scope: user/project/plugin)
func Install(ctx context.Context, m Manifest, target Target, opts ...InstallOption) error
func Diff(...) // idempotent re-install support, fingerprint-basedPer-target rendering encodes the workaround knowledge:
- Claude Code: plugin layout (
.claude-plugin/plugin.json+hooks/hooks.json— must be athooks/hooks.json, not plugin root) or settings.json fragments.Blocking: falserendersasync: trueexceptStop, which is forced synchronous (cowork drops async Stop hooks). Timeouts in seconds;SessionStartinteractive flows get raised timeouts. - Cursor:
hooks.jsonv1. Decision hooks get"failClosed": truewhenPolicyis FailClosed; telemetry hooks stay fail-open. Timeout in seconds; the library warns whenTimeout× retry budget exceeds the hook timeout. MCP double-fire handled in-binary (not via^(?!MCP:)matchers) so the correct empty-response shape is still emitted. - Codex:
hooks.json(orconfig.tomltables) + trust pre-seeding: reimplementation of Codex's definition-hash fingerprint so installs can write[hooks.state]trusted hashes.Blocking: falserenders the tee-to-tmpfile backgrounder wrapper (Codex parses-but-skipsasync: true). Emits nothing on stdout for allow. - Gemini:
settings.jsonfragment withname/description(enables/hooks enable|disableUX), timeouts converted to milliseconds, matcher dialectmcp_server_tool. - OpenCode: writes the self-contained
.opencode/plugin/agenthooks.tsshim pointing at the consumer binary (§8). - Copilot:
hooks.json(plugin roothooks/hooks.json, or.github/hooks/agenthooks.jsonat project scope) with nomatcherfield at all — an empty matcher is a validation error that discards this plugin's entire hook config, while an absent one means match-all. Onlycommandis emitted, neverbash/powershell: Copilot copiescommandinto both when absent. NofailClosedknob either — Copilot fixes the posture per event (preToolUsefail-closed, everything else fail-open).
Matchers: ToolMatcher compiles to the provider dialect where expressible
(Claude regex/exact-list rules incl. the hyphen/comma version gates, Gemini
regex-with-literal-fallback, Cursor tool-type strings, Codex regex). Where not
expressible, the generated config matches broadly and the runner filters
in-process — correctness over per-call process savings, with a Strictness
knob for hot paths.
OpenCode has no out-of-process hook protocol, so agenthooks ships two pieces:
- A generated shim plugin (
.opencode/plugin/agenthooks.ts, ~100 lines, rendered by theinstallpackage with the consumer command baked in, so there is no npm dependency): an OpenCode plugin that spawns the consumer binary inagenthooks serve --provider=opencodemode at plugin init and proxies hook invocations over NDJSON on stdio: request{seq, hook, input, output}→ response{seq, output, error?}. The shimObject.assigns the returned output (arrays replaced wholesale, preserving OpenCode's mutation semantics) and re-throwserrorto keep block-the-tool behavior.disposeterminates the daemon. The shim adds the timeout policy OpenCode lacks. provider/opencodein Go: maps shim frames into unified events. The daemon also receivesserverUrl/directory/worktreeat startup and gets an optional typed client for OpenCode's HTTP API (permission replies viaPOST /session/:id/permissions/:permissionID— the only working permission mechanism, since thepermission.askplugin hook is dead in ≥1.5.0; context injection viasession.promptwithnoReply).
This keeps the promise: consumers write the same OnToolPre handler; on
OpenCode it arrives via the shim with Raw = the exact hook input JSON.
OpenClaw's Gateway is the same in-process-plugin shape (typed api.on hooks,
no spawned-process protocol), so it reuses the serve-mode bridge with its own
dialect (payloads verified against OpenClaw 2026.6.34; quirks #34–#37):
- A generated native plugin (
openclaw.plugin.json+package.json+ plain-JSindex.js— package installs reject TypeScript entries, andpackage.json'sopenclaw.extensionsis what plugin detection keys on; installed withopenclaw plugins install <dir>+ Gateway restart) spawnsagenthooks serve --provider=openclawand proxies frames{seq, hook, event, ctx}→{seq, output?}. Unlike OpenCode's mutable-output merge, the replyoutputis returned verbatim as the hook handler's return value:{block, blockReason, requireApproval, params}onbefore_tool_call,{outcome: "block"}on a blockedbefore_agent_run(an allowed prompt returns no output — omitting the result IS the pass). Gating hooks await the reply under per-hook shim-owned deadlines (OpenClaw applies none of its own); an unreachable consumer resolves gates as timed out soManifest.Failapplies, and a fail-closed local block is reported to the daemon (gate_timeoutframe) so the denied call'safter_tool_callstill decodes as blocked. Everything else is fire-and-forget. The shim splices the cachedllm_output(finalMessage/usage) intoagent_end, and always subscribesgateway_start/gateway_stopwith a sanitized context, because the raw one carries the full Gateway config including auth secrets. provider/openclawin Go: typed views over the frames. The serve loop keeps per-connection state to backfillworkspaceDir/modelonto tool-scope frames (only conversation-scope contexts carry them) and to decode theafter_tool_callof a just-denied call astool.errorrather than a successful completion.
Coverage caveat: conversation-scope hooks require
plugins.entries.<id>.hooks.allowConversationAccess: true, and none of the
tool/llm hooks fire when the Gateway delegates the loop to the Claude Code CLI
harness (Claude-CLI-OAuth model auth) — see quirks #34/#35.
Machine-readable registry (quirks.go + generated docs), each entry:
provider, version range, affected event/capability, behavior, mitigation,
upstream reference. Seeded from provider research and production observation:
The table below is the initially seeded set; quirks.go is the authoritative
registry and has grown past it (entries #21+, including the OpenClaw rows
#34–#37).
| # | Quirk | Mitigation |
|---|---|---|
| 1 | Claude cowork skips async Stop hooks |
config gen forces async:false on Stop |
| 2 | Cursor fires preToolUse and beforeShellExecution/beforeMCPExecution for the same call |
runner dedupes into one tool.pre, keeps both raws |
| 3 | Cursor MCP: tool-name prefix; missing tool_use_id on MCP events |
strip prefix into MCPCall; synthesize stable IDs |
| 4 | Cursor 2.0.64+ requires snake_case output (user_message); 1.7 used camelCase |
emit snake_case + harmless legacy continue field |
| 5 | Cursor tool_input object on preToolUse, JSON-string on MCP events |
normalize to object; RawInput keeps original |
| 6 | Cursor CLI <2026-05-20 passes payload via argv; Windows CLI double-fires; cloud/CLI fire event subsets | argv mode; idempotency key surface for consumers; capability matrix per Variant |
| 7 | Cursor fail-open default; crashed hook allows action | failClosed in generated config on decision events |
| 8 | Codex: empty stdout = allow; unknown JSON rejected; ask/approve fail the hook run |
codec emits exact dialect; Ask degrades per policy |
| 9 | Codex hooks require user trust of definition hash | install pre-seeds [hooks.state] trusted hashes |
| 10 | Codex has no async hooks (async parsed-but-skipped) |
generated backgrounder wrapper for telemetry events |
| 11 | Gemini: exit codes ≠ docs (any non-zero except 1 blocks); stderr parsed as decision when stdout empty | runner always writes explicit JSON to stdout; never bare non-zero exits |
| 12 | Gemini hookSpecificOutput.tool_input is shallow-merge |
ErrLossyUpdate when a rewrite deletes keys; docs marker |
| 13 | Gemini additionalContext HTML-escapes </> |
documented loss; optional pre-encoding |
| 14 | Gemini timeouts in ms vs everyone's seconds | time.Duration everywhere; codecs convert |
| 15 | MCP naming: mcp__s__t (Claude/Codex) vs mcp_s_t (Gemini) vs MCP: (Cursor) vs in-context (OpenCode) |
unified MCPCall; matcher compiler |
| 16 | Claude @file reads / Bash file writes / direct /skill bypass tool hooks; Cursor AskQuestion tool fires no hooks; Codex only intercepts Bash/apply_patch/MCP |
documented blind-spot matrix per provider; Can() reflects it |
| 17 | Claude empty stdout ≠ allow; NoDecision must not force-allow |
explicit NoDecision encoding everywhere |
| 18 | OpenCode permission.ask typed but dead ≥1.5.0 |
permission flow via HTTP reply + tool.execute.before throw |
| 19 | Stop-loop guards differ (stop_hook_active, cap 8 / loop_count, loop_limit 5 or null) |
unified PreviouslyContinued + library-side continuation cap |
| 20 | Providers cross-set env (CLAUDE_PLUGIN_ROOT on Codex, CLAUDE_PROJECT_DIR on Cursor/Gemini) |
flag-first provider detection |
The registry doubles as the conformance-test plan: every quirk gets a fixture.
agenthooks/
├── agenthooks.go // Runner, Main, handler registration
├── event.go // Event, SessionInfo, ToolCall, kinds
├── decision.go // decision types + constructors
├── capability.go // capability matrix + policy
├── quirks.go // quirk registry (source of truth for docs/tests)
├── provider/
│ ├── claudecode/ // native typed structs + codec (30 events)
│ ├── codex/ // codec generated from upstream JSON schemas
│ ├── cursor/
│ ├── gemini/
│ └── opencode/ // shim wire protocol + typed hook frames
├── install/ // Manifest → rendered configs, trust seeding, fingerprint diffing
├── transcript/ // best-effort JSONL readers per provider (claude/cursor formats)
├── agenthookstest/ // fixture corpus, fake-provider harness, round-trip assertions
└── e2e/ // opt-in suite driving real local agent CLIs end to end
Testing strategy: golden fixture corpus per provider and version (captured
payloads + expected normalized event + expected encoded responses per
decision), round-trip property tests (§5.4), and a fakeagent harness that
spawns the consumer binary exactly like each provider does (stdin/argv, env,
timeout, exit-code interpretation) so consumer hooks can be integration-tested
in CI without the actual agents.
- Auth, login, identity (browser flows, device agents, credential caches): consumer concerns built on this library. agenthooks provides the hook I/O substrate those flows plug into.
- HTTP/decision-server transport: a server-authoritative decision model
is a consumer of this library — the handler body does the POST. (Claude's
native
httphook type is a possible laterinstalltarget.) - Transcript capture/dedup pipelines: the
transcriptpackage gives parsing primitives; pipelines belong to consumers. - A standalone hooks CLI / config-file DSL: library-first; Go is the DSL.
- In-process Agent-SDK hooks (Claude Agent SDK callbacks): different
runtime model; possible future
sdkbridgepackage.
- Handler concurrency: one event per process makes this moot except in OpenCode serve mode — serialize per session (matching OpenCode's sequential semantics) or allow parallel with consumer opt-in?
- Version pinning: do we gate dialect features on detected provider versions (Cursor camelCase era, Claude matcher version gates §Claude 2.1.19x) or always emit the modern + harmless-legacy superset? Proposal: superset where harmless, version-gate otherwise; revisit per quirk.
model.request/model.responsein v1? Only Gemini/OpenCode support them and they're hot-path (Gemini AfterModel fires per streamed chunk). Proposal: ship types, mark experimental, exclude from config gen defaults.- Cursor Claude-compat as an install target: rendering Claude-format config that both agents read is tempting (one file, two agents) but hits the duplicate-firing quirk and loses Cursor-only events. Proposal: native configs per provider, always.
- Codex
notifysupport: worth anotificationmapping for orgs on old Codex, or hooks-only? Proposal: ship the argv decode (it's tiny), don't advertise.