Skip to content
GN
← All Posts

Hooks Architectures Across AI Agent Ecosystems

Technical comparison of how hooks are designed and implemented across Claude Code, Cursor, OpenClaw .. etc

· 14 min read

What Is a Hook?

In AI agent frameworks, a hook is a mechanism that lets external code intercept the agent’s lifecycle at a defined point: before a tool runs, after it completes, when the session ends, and so on. The intent is consistent across platforms: observe, modify, or block agent behavior without altering the agent’s core loop.

What differs across platforms is the implementation surface:

  • Declaration format: JSON config vs. TypeScript plugin vs. Markdown-metadata handler vs. shell script
  • Communication protocol: stdin/stdout with exit codes vs. structured JSON responses vs. in-process SDK calls vs. typed event objects
  • Event model granularity: coarse lifecycle events vs. fine-grained system-level subscriptions
  • Control capabilities: blocking, input mutation, context injection, redirection
  • Isolation model: out-of-process subprocess vs. in-process runtime

 

Platform Deep Dives

Claude Code

Claude Code hooks are shell commands wired to lifecycle events via JSON configuration, declared in ~/.claude/settings.json (global) or .claude/settings.json (project-scoped).

Configuration

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/hook-script.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npx prettier --write"
          }
        ]
      }
    ]
  }
}

Communication Protocol

Hooks receive a JSON payload on stdin and communicate back via exit codes and stdout/stderr:

{
  "session_id": "abc123",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf /tmp/build" }
}

Exit code semantics:

Exit CodeBehavior
0Allow; stdout captured in transcript
1Non-blocking warning; action proceeds
2Block the action; stderr fed back to the model

For finer control, hooks emit structured JSON to stdout:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by policy"
  }
}

For Stop hooks, the response schema differs:

{ "decision": "block", "reason": "Tests have not been run yet." }

Handler Types

Claude Code supports three handler types within the same event, a distinguishing feature among the platforms compared here:

  • command: execute any shell command (default)
  • prompt: send the event payload to a Claude model for semantic evaluation (e.g., “deny if this edit touches auth code”)
  • agent: spawn a subagent with tool access (Read, Grep, Glob) for deep verification before allowing an action

This three-tier model maps to progressively deeper quality gates: syntactic enforcement, semantic evaluation, and contextual analysis.

Event Inventory

EventMatcher SupportCan Block?
PreToolUseYesYes
PostToolUseYesNo
PermissionRequestYesYes
PostToolUseFailureYesNo
UserPromptSubmitNoYes (context injection)
StopNoYes (decision: “block”)
SubagentStopNoYes
NotificationNoNo
SessionStartNoNo
SessionEndNoNo
PreCompactNoNo
InstructionsLoadedNoNo

Matcher Syntax

Matchers are case-sensitive and support pipe-delimited alternation and argument patterns:

"Bash"                  # exact tool name
"Edit|Write"            # either tool
"*"                     # all tools for this event
"Bash(npm test*)"       # tool name + argument glob
"mcp__memory__.*"       # MCP server tool pattern (regex)

Input Mutation (v2.0.10+)

PreToolUse hooks can modify tool inputs before execution. The hook reads the input JSON, mutates it, and writes valid JSON back to stdout. The modification is transparent to the model: it occurs in the host layer before the tool call is dispatched. This enables sandboxing, secret redaction, and path correction without forcing the model to retry.

Cursor

Cursor hooks (introduced in v1.7, October 2025) follow a similar stdin/stdout + JSON config pattern but differ in event naming, payload shape, and response semantics.

Configuration

Hooks are declared in .cursor/hooks.json (project) or the user’s global Cursor settings directory:

{
  "version": 1,
  "hooks": {
    "beforeShellExecution": [{ "command": "./hooks/guard.sh" }],
    "afterFileEdit": [{ "command": "hooks/audit.sh" }],
    "stop": [{ "command": "notify-send 'Agent done'" }]
  }
}

Multiple hooks for the same event all execute: configurations from project and user-global sources are merged.

Communication Protocol

Hooks receive JSON on stdin. For beforeShellExecution:

{
  "conversation_id": "668320d2-...",
  "generation_id": "490b90b7-...",
  "command": "git status",
  "cwd": "/path/to/project",
  "hook_event_name": "beforeShellExecution",
  "workspace_roots": ["/path/to/project"]
}

For beforeMCPExecution, the payload includes tool_name, tool_input, and the MCP server command key.

Hooks respond by writing JSON to stdout (not via exit codes):

{
  "continue": true,
  "permission": "allow",
  "userMessage": "Running audit first...",
  "agentMessage": "Audit passed. Proceed."
}

The permission field accepts “allow”, “deny”, or “ask”. Setting “continue”: false halts the entire agent task, not just the current tool call. userMessage and agentMessage are separate channels: one visible in the UI, one injected into the model’s context.

Event Inventory

EventCan Block?Notes
beforeSubmitPromptYesFires before prompt reaches the model
beforeShellExecutionYesPrimary enforcement point
afterShellExecutionNoObservability only
beforeMCPExecutionYesIntercepts MCP tool calls
afterMCPExecutionNo
beforeReadFileYesCan redact content before LLM ingestion
afterFileEditNoFormatters, version control triggers
beforeTabFileReadYesTab context reads
afterTabFileEditNo
afterAgentResponseNo
afterAgentThoughtNo
stopNoNotifications, cleanup

Notable gap: Cursor does not support sessionStart/sessionEnd events.

Key Differences from Claude Code

  • No config-level matchers. Filtering (e.g., only intercepting rm commands) must be implemented inside the hook script itself.
  • JSON stdout as control signal, not exit codes. Blocking requires “permission”: “deny” in stdout JSON. A known bug causes malformed JSON to silently allow commands through.
  • Bifurcated messaging. userMessage and agentMessage are separate channels. Claude Code’s equivalent is a single stderr stream visible to the model.
  • CLI parity gap. The cursor-agent CLI (as of early 2026) only reliably fires beforeShellExecution and afterShellExecution; other events remain IDE-only.

OpenCode

OpenCode takes the most architecturally distinct approach among coding-focused agents: hooks are TypeScript/JavaScript plugin methods running in-process with access to the full OpenCode SDK.

Configuration

Hooks are defined inside a plugin module, not a JSON file:

import type { Plugin } from "@anthropic-ai/opencode-plugin";

export default {
  "tool.execute.before": async (input, output) => {
    if (
      input.call.name === "Read" &&
      input.call.input.file_path?.includes(".env")
    ) {
      output.abort = "Cannot read .env files for security reasons";
    }
  },
  "tool.execute.after": async (input, output) => {
    // inspect tool result
  },
} satisfies Plugin["hooks"];

Plugin locations:

  • .opencode/plugin/: project-scoped
  • ~/.config/opencode/plugin/: user-global
  • Declared in opencode.json via a “plugin” array: for npm-distributed plugins

Event Model

OpenCode provides a rich, granular event bus covering system-level changes that Claude Code and Cursor do not expose:

CategoryEvents
Tooltool.execute.before, tool.execute.after
Sessionsession.created, session.updated, session.deleted, session.idle, session.compacted, session.status
Messagemessage.updated, message.removed, message.part.updated, message.part.removed
Filefile.edited, file.watcher.updated
Permissionpermission.replied, permission.updated
LSPlsp.client.diagnostics, lsp.updated
Commandcommand.executed

Experimental hooks extend this further:

HookPurpose
experimental.chat.messages.transformMutate the full message array before model submission
experimental.chat.system.transformRewrite or extend the system prompt at runtime
experimental.session.compactingControl or observe session compaction

In-Process Capabilities

Because hooks run as in-process TypeScript rather than subprocesses, they can:

  • Hold references to SDK objects (active session, message history)
  • Perform async operations (database lookups, network calls) natively
  • Mutate data structures directly rather than serializing to stdout
  • Subscribe to multiple unrelated events in a single module
  • Compose with other plugins via shared module state

Experimental Config-Based Hooks

For simpler use cases, OpenCode provides an experimental config layer in opencode.json:

{
  "experimental": {
    "hook": {
      "file_edited": {
        "*.ts": [
          {
            "command": ["prettier", "--write"],
            "environment": { "NODE_ENV": "development" }
          }
        ]
      },
      "session_completed": [{ "command": ["notify-send", "Session complete!"] }]
    }
  }
}

This config path is limited to two events (file_edited, session_completed) and is explicitly experimental: the plugin system is the primary integration path.

OpenClaw

OpenClaw is an open-source AI agent framework with a gateway-centric architecture and a dual extensibility surface: HOOK.md-based hooks for command and lifecycle automation, and a plugin API for tool-call interception and approval gating. Unlike Claude Code and Cursor, where a single hook system handles both lifecycle events and tool-call governance, OpenClaw separates these concerns by design. The HOOK.md hook system is primarily command-and-lifecycle-oriented, while tool-level interception lives in the plugin API via before_tool_call.

Configuration

OpenClaw hooks are TypeScript handlers paired with a HOOK.md metadata file. Each hook is a directory containing both:

my-hook/
├── HOOK.md          # YAML frontmatter metadata + documentation
└── handler.ts       # TypeScript handler implementation

The HOOK.md file declares event subscriptions, requirements, and documentation in YAML frontmatter:

---
name: my-hook
description: "Short description of what this hook does"
metadata:
  {
    "openclaw":
      {
        "emoji": "🎯",
        "events": ["command:new"],
        "requires": { "bins": ["node"] },
      },
  }
---

Hooks are enabled via JSON config in ~/.openclaw/config.json:

{
  "hooks": {
    "internal": {
      "enabled": true,
      "entries": {
        "session-memory": { "enabled": true },
        "command-logger": { "enabled": false }
      }
    }
  }
}

Discovery and Lifecycle

OpenClaw automatically discovers hooks from three directories in order of precedence:

  1. Workspace hooks (/hooks/): per-agent, highest precedence
  2. Managed hooks (~/.openclaw/hooks/): user-installed, shared across workspaces
  3. Bundled hooks (/dist/hooks/bundled/): shipped with OpenClaw

At gateway startup, OpenClaw scans these directories, parses HOOK.md frontmatter, checks eligibility (required binaries, environment variables, OS, config paths), loads eligible handlers, and registers them for their declared events. Hooks can also be distributed as hook packs via npm and installed with openclaw hooks install .

Plugins can bundle their own hooks using registerPluginHooksFromDir() from the plugin SDK. Plugin-managed hooks appear in openclaw hooks list with a plugin: prefix and are enabled/disabled by toggling the parent plugin.

Handler Implementation

Handlers export a HookHandler async function that receives a typed event object:

import type { HookHandler } from "../../src/hooks/hooks.js";

const handler: HookHandler = async event => {
  if (event.type !== "command" || event.action !== "new") {
    return;
  }

  console.log(`[my-hook] Session: ${event.sessionKey}`);

  // Push messages back to the user via the event object
  event.messages.push("✨ Hook executed successfully!");
};

export default handler;

The event context includes:

{
  type: 'command' | 'agent' | 'gateway',  // 'session' planned but not yet shipped
  action: string,              // e.g., 'new', 'reset', 'stop'
  sessionKey: string,
  timestamp: Date,
  messages: string[],          // push messages here to send to user
  context: {
    sessionEntry?: SessionEntry,
    sessionId?: string,
    commandSource?: string,    // e.g., 'whatsapp', 'telegram'
    senderId?: string,
    workspaceDir?: string,
    bootstrapFiles?: WorkspaceBootstrapFile[],
    cfg?: OpenClawConfig
  }
}

Communication is entirely in-process: no stdin/stdout serialization, no exit codes. Hooks signal to the user by pushing strings onto event.messages. There is no explicit block/allow mechanism for tool calls at the HOOK.md level; tool-call blocking and approval gating are handled through the plugin API’s before_tool_call hook and OpenClaw’s exec approvals system.

Event Model

OpenClaw’s event model spans two subsystems: HOOK.md-based hooks for command and lifecycle events, and plugin API hooks for tool-call interception.

HOOK.md Events (command and lifecycle automation):

CategoryEventsNotes
Commandcommand (all), command:new, command:reset, command:stopTriggered by slash commands
Agentagent:bootstrapHooks may mutate bootstrap file list
Gatewaygateway:startupFires after channels start

Planned future HOOK.md events include session:start, session:end, agent:error, message:sent, and message:received.

Plugin API Hooks (tool-call interception and transformation):

HookPurposeCan Block?
before_tool_callIntercepts any tool call (exec, web_fetch, write, edit, read, apply_patch) before execution. Can block, allow, or inject an async requireApproval gate.Yes
tool_result_persistSynchronous transform of tool results before transcript persistence.No
before_agent_startLegacy hook for agent initialization (deprecated; prefer before_model_resolve or before_prompt_build).No
before_model_resolveIntercepts model selection before inference.No
before_prompt_buildMutates the prompt before model submission.No

The before_tool_call hook is the primary mechanism for tool-level security policy in plugins. It receives the tool name, input payload, and session context, and can return one of three outcomes: allow the call, block it with a reason, or inject a requireApproval object that triggers a native async approval flow.

Key Architectural Differences

  • Metadata-driven discovery. OpenClaw’s HOOK.md pattern is unique: hooks self-declare their event subscriptions, requirements, and documentation in a single Markdown file with YAML frontmatter. No other platform uses this approach.
  • Eligibility gating. Hooks declare requirements (binaries on PATH, environment variables, OS, config paths) and OpenClaw checks eligibility before loading. Ineligible hooks are silently skipped.
  • CLI-first management. openclaw hooks list, openclaw hooks enable , and openclaw hooks info provide first-class lifecycle management, distinct from manually editing JSON config.
  • Dual extensibility surface. The HOOK.md system handles command and lifecycle events (/new, /reset, /stop, gateway startup). Tool-level interception lives in the plugin API via before_tool_call, which supports blocking, allowing, and async approval gating via requireApproval. This separation keeps lifecycle hooks simple while giving plugins full tool-call governance.
  • Async approval gates. The before_tool_call plugin hook can return a requireApproval object that triggers native channel-specific approval UIs: Telegram buttons, Discord interaction components, or the /approve command. This structured approval flow is unique among the platforms compared here; Claude Code and Cursor rely on modal dialogs, while OpenCode has no equivalent.
  • Multi-channel awareness. Events include commandSource and senderId, reflecting OpenClaw’s design as a multi-channel agent platform (Telegram, WhatsApp, Slack, Discord, etc.).

Bundled Hooks

OpenClaw ships four bundled hooks:

HookEventPurpose
session-memorycommand:newSaves session context to workspace memory files
bootstrap-extra-filesagent:bootstrapInjects additional workspace bootstrap files
command-loggercommandLogs all command events to a JSONL audit file
boot-mdgateway:startupRuns BOOT.md instructions via the agent runner at startup

Cross-Platform Comparison

Event Name Mapping

The same conceptual lifecycle point has different names across platforms:

Conceptual MomentClaude CodeCursorOpenCodeOpenClaw
Before tool executesPreToolUsebeforeShellExecution / beforeMCPExecutiontool.execute.beforebefore_tool_call (plugin API)
After tool completesPostToolUseafterShellExecution / afterMCPExecutiontool.execute.aftertool_result_persist (plugin API)
Agent stops respondingStopstopsession.idlecommand:stop
User submits promptUserPromptSubmitbeforeSubmitPrompt(via message events)(not exposed)
File changed(via PostToolUse on Edit/Write)afterFileEditfile.edited(not exposed)
Session lifecycleSessionStart, SessionEndnot supportedsession.created, session.deletedcommand:new, command:reset
Gateway/agent startup(not exposed)(not exposed)(not exposed)gateway:startup
Agent bootstrap(not exposed)(not exposed)(not exposed)agent:bootstrap
Prompt construction(not exposed)(not exposed)experimental.chat.system.transformbefore_prompt_build (plugin API)

Architecture Comparison

DimensionClaude CodeCursorOpenCodeOpenClaw
Hook definitionJSON configJSON configTypeScript pluginHOOK.md + handler.ts (lifecycle); plugin API (tool calls)
Execution modelOut-of-process subprocessOut-of-process subprocessIn-process async functionIn-process async function (gateway)
Communicationstdin JSON → exit code + stdoutstdin JSON → stdout JSONDirect function call / object mutationTyped event object + messages[] push (hooks); return value / requireApproval (plugin API)
Blocking signalExit code 2 or “decision”: “block""permission”: “deny” in stdoutoutput.abort = “reason”before_tool_call return: block reason or requireApproval (plugin API)
Matcher / filteringConfig-level (tool name, regex, argument glob)Script-internal logicPlugin-internal logicEvent key in HOOK.md metadata (hooks); tool name matching in plugin code (plugin API)
Handler typescommand, prompt, agentcommand onlyTypeScript function onlyTypeScript function only
Input mutationYes (PreToolUse, v2.0.10+)NoYes (via output object)Yes (agent:bootstrap mutates bootstrap files); before_prompt_build mutates prompts
Context injectionadditionalContext fieldagentMessage fieldDirect message transform hooksevent.messages[] push
Config location~/.claude/settings.json, .claude/settings.json~/.cursor/hooks.json, .cursor/hooks.json~/.config/opencode/plugin/, .opencode/plugin/~/.openclaw/config.json, /hooks/, openclaw.plugin.json
Event granularityMedium (12 events)Medium (11+ events)High (20+ events incl. LSP, message parts)Medium (7 HOOK.md events + 5+ plugin API hooks)
Discovery modelStatic configStatic configPlugin directory scanAuto-discovery with eligibility gating (hooks); manifest-based (plugins)
DistributionConfig files / shell scriptsConfig files / shell scriptsnpm packagesnpm hook packs, plugin-bundled hooks, npm plugin packages

Protocol Comparison

Claude Code treats hooks as autonomous processes speaking a minimal protocol. The process receives data, executes arbitrary logic, and signals back through exit codes. Structured JSON on stdout is opt-in, required only for nuanced decisions. This makes hooks writable in any language: bash, Python, Go, or anything that can read stdin and exit.

Cursor also runs hooks as subprocesses but requires JSON on stdout for any meaningful response. The exit code is not the control signal; the JSON payload is. This is more expressive (separate user/agent messaging channels) but more brittle: a hook that crashes or produces malformed JSON silently allows the action through rather than failing safe.

OpenCode eliminates the subprocess boundary entirely. The hook is the plugin runtime. This provides maximum expressive power but couples hook authors to TypeScript and the OpenCode plugin SDK. There is no stdin: the hook receives a typed input object and mutates a typed output object. Side effects are plain async/await.

OpenClaw runs both hooks and plugin code in-process but separates them into two distinct subsystems. HOOK.md-based hooks are gateway-level automation: they receive a typed event object with session context, command metadata, and a message channel for user feedback. These hooks are fire-and-forget on commands and lifecycle events with no block/allow semantics for tool calls. Tool-level interception lives in the plugin API via before_tool_call, which receives the tool name, input payload, and session context, and can block the call, allow it, or inject a requireApproval gate that surfaces native approval UIs across channels (Telegram buttons, Discord components, /approve command). This separation keeps lifecycle hooks simple while giving plugins full tool-call governance, including an async approval flow that no other platform in this comparison offers at the hook level.

Design Implications

Out-of-Process vs. In-Process

The Claude Code / Cursor subprocess model is language-agnostic and failure-isolated: a crashing hook does not bring down the agent. The tradeoff is serialization overhead and the inability to maintain stateful references across hook invocations.

OpenCode and OpenClaw both run hooks in-process, making them stateful and expressive but coupling authors to TypeScript. OpenClaw mitigates crash risk through its eligibility-gating system: hooks that declare unmet requirements are never loaded.

Fail-Open vs. Fail-Closed

Claude Code is effectively fail-open by default for most hooks: if a hook errors (exit code 1), the action proceeds. Only exit code 2 blocks. This is an intentional design choice to prevent hooks from inadvertently breaking the agent loop. Security-focused hooks must be careful to exit 2 (not 1) when enforcing policy.

Cursor has a known fail-open defect: if a beforeShellExecution hook returns malformed JSON, Cursor silently allows the command. This is a structural risk for any security hook that depends on “permission”: “deny”.

OpenCode’s output.abort model is cleaner in this regard: setting a string property on the output object is less failure-prone than serializing JSON to stdout.

OpenClaw’s HOOK.md hooks sidestep the fail-open/fail-closed question for tool calls entirely since they don’t intercept tool calls. For tool-level blocking, the plugin API’s before_tool_call hook and the exec approvals pipeline each have their own safety semantics. Notably, on text-based connectors (Telegram, WhatsApp), paranoid sensitivity mode promotes all ask verdicts to deny to mitigate prompt-injection auto-approval risks a channel-aware safety design that reflects OpenClaw’s multi-channel architecture.

Matcher Expressiveness

Claude Code’s config-level matchers (tool name, pipe-alternation, argument glob, MCP server regex) allow scoping hook execution without modifying hook code. A hook targeting only Bash(git push*) requires no internal branching logic.

Cursor and OpenCode require filtering logic inside the hook script or plugin function. This is more flexible in principle but adds boilerplate to every hook that needs scoping.

OpenClaw’s HOOK.md metadata declares event subscriptions declaratively (e.g., [“command:new”]), which is clean for command-level scoping but does not extend to tool-name-level filtering since HOOK.md hooks do not intercept tool calls. Tool-name filtering is handled in plugin code via before_tool_call, where the plugin inspects the tool name and input payload directly similar to OpenCode’s approach of plugin-internal filtering logic.

Event Granularity Trade-Offs

OpenCode’s 20+ event model (including LSP diagnostics, message part updates, session compaction) supports reactive tooling: plugins that respond to editor state, not just tool calls. This enables capabilities like live lint-on-edit and session summarization triggers that Claude Code and Cursor hooks cannot express.

OpenClaw occupies a middle ground when both subsystems are considered together. The HOOK.md system has a focused set of command and lifecycle events, while the plugin API adds tool-call interception (before_tool_call, tool_result_persist), prompt mutation (before_prompt_build), and model resolution (before_model_resolve). This combined surface is intentionally split: HOOK.md hooks serve a narrower automation role with auto-discovery and eligibility gating, while the plugin API handles the heavier extensibility work (tools, channels, model providers, tool-call governance). Planned HOOK.md events (session:start, session:end, message:sent, message:received) will expand the lifecycle surface without changing the core architecture.

The cost of high granularity (OpenCode) is complexity: more events mean more surface area to understand and more potential for plugin interactions to produce unexpected behavior. The cost of OpenClaw’s dual-surface approach is that developers must understand which subsystem to use for a given use case: HOOK.md for lifecycle automation, plugin API for tool-call governance and prompt mutation.

Summary

Claude CodeCursorOpenCodeOpenClaw
ParadigmConfig-driven subprocessConfig-driven subprocessPlugin-native in-processGateway-native in-process (dual surface: HOOK.md + plugin API)
StrengthMulti-handler types, input mutation, matcher systemBifurcated user/agent messaging, MCP-specific eventsEvent richness, stateful access, TypeScript ergonomicsAuto-discovery with eligibility gating, before_tool_call with requireApproval async gates, multi-channel awareness
WeaknessSubprocess overhead, no direct SDK accessNo config-level matchers, fail-open JSON defect, CLI gapTypeScript lock-in, no subprocess isolationDual-surface complexity (HOOK.md vs. plugin API), limited HOOK.md event surface
Best forSecurity gates, quality enforcement, deep verification via agent hooksIDE-integrated governance, MCP interception, observabilityEvent-reactive plugins, system prompt transformation, stateful cross-event logicMulti-channel agent governance, tool-call security with structured approval flows, session lifecycle automation

Hooks are conceptually simple intercept, inspect, decide but each platform’s implementation reflects fundamentally different architectural priorities. Claude Code optimizes for expressive control with language freedom. Cursor optimizes for IDE integration and dual-channel messaging. OpenCode optimizes for programmatic power and event granularity. OpenClaw optimizes for multi-channel agent governance, separating lifecycle automation (HOOK.md hooks) from tool-call security policy (plugin API with before_tool_call and requireApproval). Understanding these differences is essential for anyone building cross-platform agent tooling, security layers, or unified observability the divergence in hook surfaces means that any abstraction spanning multiple platforms must adapt to each model rather than assume a common interface.

References


Share