Skip to main content

Hooks: Event-Driven Automation

Hooks are your commands that run automatically when your agent does something.

  • The agent edits a file → your formatting command runs
  • The agent runs a bash command → your logging command runs
  • You submit a prompt → your context injection runs
  • Session starts → your setup script runs

Why this matters: You can tell Claude or OpenCode "always format code after editing": but the agent might forget. A hook guarantees it happens every time, because it's your code running automatically, not the agent choosing to run it.

Both tools, different surfaces

Claude Code and OpenCode both expose hook systems, but the surface differs. Claude Code uses a JSON config block with five named events (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, SessionEnd) and matcher patterns. OpenCode uses a TypeScript/JavaScript plugin API where you write functions that subscribe to events (tool.execute.before, tool.execute.after, chat.params, chat.message, plus file/permission/LSP events). Same underlying idea, different idioms. The lesson shows both side by side.


Why Hooks?

Without hooks, you hope Claude remembers to:

  • Format code after editing
  • Run tests after changes
  • Follow your naming conventions
  • Avoid touching sensitive files

With hooks, you guarantee these happen:

  • PostToolUse hook runs Prettier after every file edit
  • PreToolUse hook blocks edits to .env files
  • SessionStart hook loads project context automatically
  • Notification hook sends Slack alerts when Claude needs input

The key insight: By encoding rules as hooks instead of prompting instructions, you turn suggestions into app-level code that executes every time.


The Five Main Hook Events

EventWhen It FiresCommon Use Cases
PreToolUseBefore a tool runsValidate commands, block dangerous operations, modify inputs
PostToolUseAfter a tool completesFormat code, run tests, log activity
UserPromptSubmitWhen you submit a promptAdd context, validate input, inject system info
SessionStartWhen Claude Code startsLoad environment variables, show project info
SessionEndWhen session closesCleanup, save logs

Claude Code supports over 20 hook events beyond these five. You may encounter Stop (when Claude finishes responding), Notification (when Claude needs your attention), PermissionRequest (when a permission dialog appears), SubagentStart/SubagentStop (for subagent lifecycle), and others for compaction, config changes, and worktrees. Chapter 17 covers advanced hook events in depth.

OpenCode does not use named hook events in JSON. It exposes a TypeScript/JavaScript plugin API where each plugin function returns an object whose keys are event names. The closest mapping to Claude Code's five events:

Claude Code eventOpenCode equivalentNotes
PreToolUsetool.execute.beforeReceives (input, output); mutate output.args or throw to block
PostToolUsetool.execute.afterFires after a tool completes
UserPromptSubmitNo direct equivalentUse the generic event handler and filter on message.updated
SessionStartsession.created (via event handler)Fires when a new session is created
SessionEndsession.idle (via event handler)Fires when the session goes idle

OpenCode also exposes events for files (file.edited, file.watcher.updated), permissions (permission.asked, permission.replied), LSP (lsp.client.diagnostics), shell (shell.env), commands (command.executed), and TUI (tui.toast.show). See the OpenCode plugins reference for the full list.


How Hooks Work

Event fires → Hook script runs → Script output affects Claude

The pattern:

  1. An event occurs (e.g., you submit a prompt)
  2. Claude Code runs your hook script
  3. Script receives JSON input via stdin
  4. Script produces output via stdout
  5. Output gets injected into Claude's context

Exit codes matter:

  • 0 = Success (stdout processed)
  • 2 = Block the action (show error)
  • Other = Non-blocking warning
Event fires → Plugin handler runs → Mutated output (or thrown error) affects OpenCode

The pattern:

  1. An event occurs (e.g., a tool is about to run)
  2. OpenCode invokes the matching handler in your plugin module
  3. The handler receives (input, output) JavaScript objects (no stdin)
  4. The handler mutates output to change behavior, or throws to block
  5. The handler returns; control passes to the next plugin or the engine

Control flow:

  • Return normally = success
  • throw new Error("reason") = block the action and surface the error
  • Mutate output.args (in tool.execute.before) to rewrite the call
  • Mutate output.env (in shell.env) to inject environment variables

Configuring Hooks

Option 1: Use the /hooks Command (Easiest)

Run:

/hooks

This opens an interactive menu where you:

  1. Select an event (PreToolUse, PostToolUse, etc.)
  2. Add a matcher (which tools to match)
  3. Add your hook command
  4. Choose storage location (User or Project)

Option 2: Edit settings.json Directly

Hooks are configured in .claude/settings.json:

{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/your-script.sh"
}
]
}
]
}
}

Key fields:

  • EventName: Which event triggers this (PreToolUse, PostToolUse, etc.)
  • matcher: Which tools to match (e.g., Bash, Write, Edit, Read)
  • command: The script to run

Matcher Patterns

PatternMatches
"Bash"Only Bash tool
"Write|Edit"Write OR Edit tools
"Notebook.*"All Notebook tools
"" or omitAll tools (for that event)

OpenCode has no /hooks command and no JSON config block for events. You configure behavior by writing a TypeScript or JavaScript plugin file and dropping it in a plugin directory.

Create a file under one of these directories:

  • .opencode/plugins/ for project-level plugins
  • ~/.config/opencode/plugins/ for global plugins

Files in these directories are automatically loaded at startup.

.opencode/plugins/example.ts
import type { Plugin } from "@opencode-ai/plugin";

export const ExamplePlugin: Plugin = async ({
project,
client,
$,
directory,
worktree,
}) => {
return {
"tool.execute.before": async (input, output) => {
// Runs before any tool call. `input.tool` is the tool name.
},
"tool.execute.after": async (input, output) => {
// Runs after any tool call.
},
};
};

Option 2: npm package

Reference a published plugin in opencode.json:

opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-helicone-session", "@my-org/custom-plugin"]
}

Key concepts:

  • Each plugin is an async function that receives a context (project, directory, worktree, client, $) and returns an object of event handlers.
  • The object's keys are event names (tool.execute.before, session.idle, shell.env, etc.).
  • There is no separate matcher field. You filter inside the handler: if (input.tool === "bash") { ... }.

"Matcher" equivalents

OpenCode does not have matcher patterns. You write the filter as code at the top of the handler:

Claude Code matcherOpenCode handler check
"Bash"if (input.tool === "bash") { ... }
"Write|Edit"if (input.tool === "write" || input.tool === "edit")
"Notebook.*"if (input.tool.startsWith("notebook"))
"" (all tools)No if check; handler runs for every tool call

Try It Now: Your First Hook

Let's log every Bash command the agent runs.

Prerequisite: Install jq for JSON processing (brew install jq on macOS, apt install jq on Linux).

Method 1: Using /hooks (Quickest)

  1. Run /hooks in Claude Code
  2. Select PreToolUse
  3. Add matcher: Bash
  4. Add hook command:
    jq -r '"\(.tool_input.command) - \(.tool_input.description // "No description")"' >> ~/.claude/bash-log.txt
  5. Choose User settings for storage
  6. Press Esc to save

Now ask Claude to run ls and check your log:

cat ~/.claude/bash-log.txt

Method 2: Edit settings.json Directly

Add to .claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"\\(.tool_input.command) - \\(.tool_input.description // \"No description\")\"' >> ~/.claude/bash-log.txt"
}
]
}
]
}
}

Restart Claude Code and test it.

OpenCode has only one method: write a plugin file. There is no interactive menu.

Create .opencode/plugins/log-bash.ts:

.opencode/plugins/log-bash.ts
import type { Plugin } from "@opencode-ai/plugin";
import { appendFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

export const LogBashPlugin: Plugin = async () => {
const logPath = join(homedir(), ".opencode", "bash-log.txt");
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== "bash") return;
const command = output.args.command ?? "";
const description = output.args.description ?? "No description";
await appendFile(logPath, `${command} - ${description}\n`);
},
};
};

Restart OpenCode. Ask the agent to run ls, then check the log:

cat "$HOME/bash-log.txt"

Note the differences:

  • No jq. The handler receives a JavaScript object directly; no JSON parsing step.
  • No matcher field. The if (input.tool !== "bash") return line is the matcher.
  • No exit codes. The handler returns normally on success and throws to block.

Real Example: UserPromptSubmit Hook

Here's a real hook that tracks prompts (from this book's codebase).

Script (.claude/hooks/track-prompt.sh):

#!/usr/bin/env bash
# Track user prompt submissions

# Read JSON input from stdin
INPUT=$(cat)

# Parse the prompt field
PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')

# Skip if no prompt
[ -z "$PROMPT" ] && exit 0

# Log it
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "{\"timestamp\": \"$TIMESTAMP\", \"prompt\": \"$PROMPT\"}" >> .claude/activity-logs/prompts.jsonl

exit 0

Configuration:

{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/track-prompt.sh"
}
]
}
]
}
}

What happens:

  1. You submit a prompt
  2. Hook receives JSON: {"prompt": "your message", "session_id": "..."}
  3. Script extracts prompt, logs it with timestamp
  4. Session continues normally

OpenCode has no UserPromptSubmit event. The closest signal is the generic event handler, which fires for every emitted event including message.updated (when a new user message is added to a session).

.opencode/plugins/track-prompt.ts
import type { Plugin } from "@opencode-ai/plugin";
import { appendFile, mkdir } from "node:fs/promises";

export const TrackPromptPlugin: Plugin = async ({ directory }) => {
const logPath = `${directory}/.opencode/activity-logs/prompts.jsonl`;
await mkdir(`${directory}/.opencode/activity-logs`, { recursive: true });

return {
event: async ({ event }) => {
// Filter for new user messages
if (event.type !== "message.updated") return;
const message = event.properties?.info;
if (message?.role !== "user") return;

const text =
message.parts
?.filter((p: { type: string }) => p.type === "text")
?.map((p: { text: string }) => p.text)
?.join("\n") ?? "";
if (!text) return;

const entry = JSON.stringify({
timestamp: new Date().toISOString(),
prompt: text,
});
await appendFile(logPath, entry + "\n");
},
};
};

What happens:

  1. You submit a prompt; OpenCode emits a message.updated event
  2. The event handler receives the event payload as a JavaScript object
  3. The handler filters for user messages, extracts text parts, and appends a log line
  4. Session continues normally

Honest gap: this fires after the message is added, not before submission. There is currently no way to mutate or block a user prompt before the model sees it.


Real Example: PreToolUse Hook

Track when a specific tool is invoked.

Track when skills are invoked:

Configuration:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/track-skill-invoke.sh"
}
]
}
]
}
}

What this does:

  • Fires before the Skill tool runs
  • Only matches the Skill tool (not Bash, Write, etc.)
  • Can log, validate, or modify the tool call

OpenCode does not have a built-in Skill tool surface; agents and mode configurations cover much of that ground. The same pattern applies to any tool you want to instrument. This example tracks bash invocations and can also rewrite the arguments before execution:

.opencode/plugins/track-tool.ts
import type { Plugin } from "@opencode-ai/plugin";

export const TrackToolPlugin: Plugin = async ({ client }) => {
return {
"tool.execute.before": async (input, output) => {
if (input.tool !== "bash") return;
await client.app.log({
body: {
service: "track-tool",
level: "info",
message: "bash invocation",
extra: { args: output.args },
},
});
// To block the call, throw an error here:
// throw new Error("blocked by policy")
},
};
};

What this does:

  • Fires before any tool runs; the if line is the matcher
  • Logs structured data via the OpenCode SDK
  • Can mutate output.args to rewrite the call, or throw to block it

Real Example: PostToolUse Hook

Track tool results after completion.

Track subagent results:

Configuration:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/track-subagent-result.sh"
}
]
}
]
}
}

What this does:

  • Fires after the Task tool completes
  • Receives the task result in JSON input
  • Can log, analyze, or trigger follow-up actions
.opencode/plugins/track-result.ts
import type { Plugin } from "@opencode-ai/plugin";

export const TrackResultPlugin: Plugin = async ({ client }) => {
return {
"tool.execute.after": async (input, output) => {
// Filter to whichever tool you care about; e.g. "task" for subagent calls
if (input.tool !== "task") return;
await client.app.log({
body: {
service: "track-result",
level: "info",
message: "task completed",
extra: { output },
},
});
},
};
};

What this does:

  • Fires after the matched tool completes
  • Receives the result on output as a JavaScript object
  • Can log, analyze, or trigger follow-up actions (cannot retroactively block)

Hook Input Format

All hooks receive JSON via stdin. Common fields:

{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run tests"
}
}

Event-specific fields:

  • UserPromptSubmit: {"prompt": "user's message"}
  • PreToolUse/PostToolUse: {"tool_name": "...", "tool_input": {...}}
  • SessionStart: Basic session info

Hook Output Format

Simple: Just print text to stdout:

echo "Current time: $(date)"
exit 0

Advanced: Output JSON for more control:

echo '{"decision": "allow", "reason": "Auto-approved"}'
exit 0

Block an action:

echo "Blocked: dangerous command" >&2
exit 2
PreToolUse Permission Decisions

For PreToolUse hooks that make permission decisions, the official docs use a structured hookSpecificOutput object containing fields like permissionDecision and additionalContext. The simple format above works for basic allow/block logic; see the official hooks reference for the full schema when building permission-aware hooks.


Beyond Command Hooks

This lesson teaches "type": "command" hooks, where the hook runs a shell script. Claude Code actually supports four hook types:

  1. command runs a shell command (what you learned above).
  2. http sends the event data as a POST request to a URL endpoint, useful for team-wide audit services and cloud integrations.
  3. prompt evaluates a single-turn LLM prompt, letting a hook use AI judgment rather than deterministic logic.
  4. agent spawns a subagent with tool access to verify conditions, combining hook triggers with full agent capabilities.

The prompt and agent types represent a significant shift: hooks can now make AI-powered decisions, not just rule-based ones. Chapter 17 covers these advanced hook types in detail.


Combining Multiple Hooks

You can have multiple hooks for the same event:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/validate-bash.sh"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/check-files.sh" }
]
}
]
}
}

Different matchers trigger different scripts based on which tool is used.


Debugging Hooks

If hooks aren't working:

  1. Check the script is executable: chmod +x .claude/hooks/your-script.sh
  2. Test manually: echo '{"test": "data"}' | bash .claude/hooks/your-script.sh
  3. Check settings.json syntax: Valid JSON? Correct structure?
  4. Use debug mode: claude --debug shows hook execution

Where Hooks Live

.claude/
├── settings.json # Hook configuration
└── hooks/ # Hook scripts
├── _common.sh # Shared utilities (optional)
├── session-info.sh
├── track-prompt.sh
└── validate-bash.sh

Tip: Use a _common.sh file for shared functions like JSON parsing.


What's Next

Lesson 16 introduces Plugins:pre-packaged bundles of skills, hooks, agents, and MCP servers that you can install from marketplaces. Where hooks let you customize Claude Code's behavior, plugins let you install complete capability packages built by others.


Try With AI

The five prompts below run in either Claude Code or OpenCode. Where the surface differs (settings.json vs a TypeScript plugin file), ask your agent for the equivalent in the tool you actually use.

📝 Create a Simple Hook:

"Help me create a session-start hook that shows the git branch and last commit message when I start my agent. In Claude Code, build it with a SessionStart hook in settings.json. In OpenCode, build it as a plugin function subscribed to session.created in .opencode/plugins/. Walk me through whichever I'm using: the script, the config, and how to test it."

What you're learning: The complete hook lifecycle, from script to configuration to testing. This pattern applies across both tools and all hook types.

🔍 Understand Hook Events:

"I want to automatically run prettier after my agent edits a JavaScript file. Which hook event should I use? In Claude Code, that's PostToolUse with a matcher. In OpenCode, that's a tool.execute.after (or file.edited) handler in a plugin. Show me the complete configuration for the tool I'm using."

What you're learning: Event selection and pattern matching: choosing the right trigger and scope for automated behavior.

🛡️ Validation Hook:

"Help me create a hook that warns me before my agent runs any command with 'rm' or 'delete' in it. The hook should print a warning but not block the command. In Claude Code, use PreToolUse on the Bash tool. In OpenCode, use tool.execute.before and inspect the args."

What you're learning: Safety guardrails through hooks: implementing 'soft' warnings that inform without blocking, a pattern used in production systems.

📊 Logging Hook:

"I want to log all the tools my agent uses during a session. Help me build it: in Claude Code, a PostToolUse hook that appends tool names and timestamps to a log file; in OpenCode, a tool.execute.after handler that does the same."

What you're learning: Observability through hooks: instrumenting AI behavior for debugging and analysis. This is how production systems gain visibility.

🔧 Debug a Hook:

"My hook isn't running. Help me debug. For Claude Code: How do I test the script manually? How do I check settings.json is correct? What does claude --debug show? For OpenCode: How do I confirm my plugin file is being loaded? How do I add console.log and read the output?"

What you're learning: Hook debugging methodology: the systematic approach when automation doesn't work. This skill saves significant debugging time across both tools.

Flashcards Study Aid