Hook Events and the Agent Lifecycle
Before wiring anything up, it helps to know which events actually fire and when. The vocabulary is slightly different on each platform, but the lifecycle they describe is shared.
The Shared Lifecycle
At the abstract level, every agent session moves through the same phases:
┌─────────────────────────────────────────────┐
│ Session starts │ ← SessionStart
├─────────────────────────────────────────────┤
│ Repeat for each turn: │
│ User submits a prompt │ ← UserPromptSubmit
│ Model reasons and may call tools │
│ Before each tool call │ ← PreToolUse
│ After each tool call │ ← PostToolUse
│ Turn ends │ ← Stop
├─────────────────────────────────────────────┤
│ Session ends │ ← SessionEnd
└─────────────────────────────────────────────┘This is the shared mental model. The platforms in this course map onto these moments with different names, different configuration surfaces, and a few gaps or extra events.
Events by Platform
Claude Code has the richest event set of the four platforms. Every event uses PascalCase and is configured in .claude/settings.json (or inside a plugin's hooks/hooks.json).
Core lifecycle events:
SessionStart— New session begins (fresh, resume, or compaction continuation).InstructionsLoaded—CLAUDE.mdfiles have been loaded.UserPromptSubmit— User submitted a prompt, before the model sees it.PreToolUse— Before a tool call runs.PermissionRequest— Permission prompt about to be shown.PermissionDenied— A tool call was denied by the auto-mode classifier.PostToolUse— After a tool call returns.PostToolUseFailure— Tool call failed.Stop— Turn finished.SessionEnd— Session closed.
Subagent and task events:
SubagentStart,SubagentStop— A delegated subagent starts or finishes.TaskCreated,TaskCompleted— The TodoWrite task list changes.
Environment events:
CwdChanged,FileChanged— Working directory or a tracked file changed.WorktreeCreate,WorktreeRemove— Git worktree created or removed.PreCompact,PostCompact— Context compaction about to happen / just finished.ConfigChange,Notification— Settings changed; Claude raised a notification.
Matcher groups filter on event-specific fields. Tool events match tool names (for example, "matcher": "Bash" or "matcher": "Edit|Write"), while handler-level if conditions can use permission-rule syntax to inspect tool arguments (for example, "if": "Bash(rm *)").
Codex hooks are experimental and must be enabled explicitly. In ~/.codex/config.toml:
[features]
codex_hooks = trueCodex ships a smaller core set of events, all PascalCase, configured in ~/.codex/hooks.json or <repo>/.codex/hooks.json:
SessionStart— Session begins. Matcher filters thesourcefield:startup,resume, orclear.UserPromptSubmit— User prompt submitted.PreToolUse— Before a supported tool call, includingBash,apply_patch(Edit|Writealiases), and MCP tools.PermissionRequest— Permission prompt about to be shown.PostToolUse— After a supported tool call, includingBash,apply_patch(Edit|Writealiases), and MCP tools.Stop— Turn finished.
The Codex event set is intentionally close to the classic Claude Code lifecycle, which makes cross-agent hook scripts easier to share — but note that the tool-event surface is still narrower than Claude Code's and does not intercept every tool path.
Matchers are regexes evaluated against tool_name for tool events or source for SessionStart. Codex hooks are changing quickly, so small details may differ between Codex versions. The examples below show the event set used in this course.
OpenCode does not have a JSON event config. Plugins are TypeScript or JavaScript modules in .opencode/plugins/, and each exported plugin returns an object whose keys are event names. The runtime calls the matching key when the event fires.
Typed hook keys (first-class):
"tool.execute.before"— About to execute a tool."tool.execute.after"— Tool execution finished."shell.env"— About to launch a shell command; mutate the environment."experimental.session.compacting"— Session is being compacted.
Generic event callback — receives \{ event \} with an event.type field. Types include lifecycle and UI events like:
session.created,session.updated,session.idle,session.compacted,session.deleted,session.error,session.diff,session.statusmessage.updated,message.removed,message.part.updated,message.part.removedcommand.executed,file.edited,file.watcher.updatedpermission.asked,permission.repliedlsp.client.diagnostics,lsp.updatedtodo.updated,server.connected,installation.updatedtui.prompt.append,tui.command.execute,tui.toast.show
OpenCode has no UserPromptSubmit event. The nearest equivalent is reading the new user message through the generic event callback when message.updated fires.
Pi hooks live inside TS/JS extensions in .pi/extensions/ or ~/.pi/agent/extensions/. The built-in lifecycle is lower_snake_case:
Core lifecycle events:
session_start— Session begins (startup,reload,new,resume, orfork)before_agent_start— User prompt received; can inject messages or edit the system promptagent_start— Agent loop beginsturn_start,turn_end— One model turn starts or finishestool_call— Before a tool executes; can mutate args or blocktool_result— After a tool executes; can rewrite the resultagent_end— Request finishedsession_shutdown— Extension runtime is being torn down
Session/control events:
session_before_switch,session_before_fork— Intercept/new,/resume,/fork, or/clonesession_before_compact,session_compact,session_before_tree,session_tree— Context management and tree navigationmodel_select,user_bash— Model changes and user-initiated shell commands
Pi doesn't use a JSON hook file. You register handlers in code with pi.on("event_name", handler) and ship them in an extension or Pi package.
Event Input: What Your Hook Receives
Command hooks receive a JSON payload on stdin. HTTP hooks receive the same payload as the POST body. Common fields across events:
{
"session_id": "...",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/path/to/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}Tool-related events add tool_name, tool_input, and (on PostToolUse) tool_response. Subagent events add agent_id and agent_type. The project root is also available as an environment variable CLAUDE_PROJECT_DIR.
Command hooks receive JSON on stdin. Common fields:
{
"session_id": "...",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"model": "gpt-5-codex"
}Turn-scoped events add turn_id. Tool events add tool_name, tool_use_id, and tool_input; for Bash and apply_patch, the command is in tool_input.command. PostToolUse also includes tool_response. UserPromptSubmit adds prompt. Stop adds stop_hook_active and last_assistant_message.
OpenCode hooks are JS/TS functions, not stdin scripts. Each event type has a typed (input, output) signature:
"tool.execute.before": async (input, output) => {
// input.tool — tool name (e.g. "read", "bash")
// input.sessionID — session identifier
// output.args — tool arguments (mutable)
}"tool.execute.after" adds the result on output. "shell.env" gives you output.env to mutate. The generic event callback receives \{ event \} where event.type identifies the event.
The plugin also receives a context object at construction time: \{ project, directory, worktree, client, $ }. Use client.app.log({...}) for structured logging and $ (Bun shell) to run commands.
Pi hooks are TS/JS functions registered in an extension. Common event shapes look like this:
pi.on("before_agent_start", async (event, ctx) => {
// event.prompt — user prompt text
// event.systemPrompt — current system prompt for this turn
// event.images — attached images (if any)
});
pi.on("tool_call", async (event, ctx) => {
// event.toolName — "bash", "read", "write", etc.
// event.toolCallId — unique tool call id
// event.input — mutable tool arguments
});
pi.on("tool_result", async (event, ctx) => {
// event.toolName — tool name
// event.input — final tool arguments
// event.content — tool result content blocks
// event.details — structured tool details
// event.isError — whether the tool failed
});For nested async work inside a handler, use ctx.signal so Esc can cancel the extension's own fetch() calls or other abort-aware work.
How Hooks Influence the Agent
Hooks are not purely observational — they can change what happens next.
Exit codes (command hooks):
- Exit
0— allow, no change. - Exit
2with a message on stderr — block or continue per event semantics (e.g. block the tool call onPreToolUse, erase the prompt onUserPromptSubmit).
JSON on stdout for finer control:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "No network access in this project"
}
}Also supported: top-level continue, stopReason, suppressOutput, systemMessage, and the legacy \{ "decision": "block", "reason": "..." \} form.
HTTP hooks return the same JSON as a 2xx response body. Non-2xx responses or timeouts are treated as non-blocking errors.
Exit codes:
0— success.2with stderr message — block or continue per event.
JSON on stdout uses a shape similar to Claude Code's:
{
"systemMessage": "Injected context for the model",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "No network access in this project"
}
}For SessionStart, UserPromptSubmit, and PostToolUse, hookSpecificOutput can include additionalContext to inject text into the conversation.
OpenCode hooks influence the agent by mutating the output object or by throwing. To rewrite a tool call, mutate output.args. To block it, throw an error:
"tool.execute.before": async (input, output) => {
if (input.tool === "bash" && /rm -rf/.test(output.args.command ?? "")) {
throw new Error("dangerous command blocked by policy")
}
}There are no exit codes or JSON-over-stdin. All influence happens through code.
Pi hooks influence the agent by returning structured values or mutating event state in code.
- Return
\{ block: true, reason: "..." \}fromtool_callto cancel a tool. - Mutate
event.inputinsidetool_callto rewrite arguments before execution. - Return
\{ content, details, isError \}fromtool_resultto rewrite a tool result. - Return
messageorsystemPromptfrombefore_agent_startto inject context into the next turn.
pi.on("tool_call", async (event) => {
if (event.toolName === "bash" && /rm -rf/.test(event.input.command as string)) {
return { block: true, reason: "dangerous command blocked by policy" };
}
});
pi.on("before_agent_start", async (event) => ({
systemPrompt: event.systemPrompt + "\n\nAlways explain risky shell commands before running them.",
}));Choosing the Right Event
A quick reference for common goals:
- Log every tool call →
PreToolUse/tool.execute.before/tool_call. - Run a linter after an edit →
PostToolUse(matchEdit|Write) /tool.execute.after/tool_result. - Block dangerous commands →
PreToolUsewith exit code2, a thrown error, ortool_callwith\{ block: true \}. - Inject repo context on each turn →
UserPromptSubmitwithadditionalContext(Claude Code / Codex), theeventcallback onmessage.updated(OpenCode), orbefore_agent_start(Pi). - Persist conversation state →
Stop/SessionEnd,session.idleon OpenCode, oragent_end/session_shutdownon Pi. - Add environment variables to shells →
shell.env(OpenCode), or mutatetool_call/ wrap user bash handling in an extension on Pi.
Key Takeaways
The platforms expose the same underlying lifecycle with different vocabulary and different levels of granularity. Claude Code has the richest event set and supports four handler types. Codex has a smaller, tightly focused set behind a feature flag. OpenCode folds hooks into its plugin system as typed function keys rather than a JSON config. Pi uses extension events such as before_agent_start, tool_call, and tool_result. Once you pick an event for the goal at hand, the rest of the work is the same across them.
Next, a quick quiz before we wire these events into a live Gradio dashboard.