Claude Code Agent Loop Deep Dive (2): Hooks as Programmable Intervention Points
The previous article explained permission approval in the loop: after the LLM emits tool_use but before the tool actually executes, an interception layer lets the user decide. But users may want to add custom logic to t
The previous article explained permission approval in the loop: after the LLM emits tool_use but before the tool actually executes, an interception layer lets the user decide.
But users may want to add custom logic to the loop for far more than tool approval:
- inspect every Bash command before it runs;
- run a formatter after every Edit;
- load shared project rules at session start;
- snapshot the conversation before compaction;
- prevent the loop from stopping until it has performed another check.
These are all hooks. Hooks are the general mechanism for placing custom logic inside an agent loop. Permission approval is one specialized use of that general capability.
This article asks:
- How many hook events exist, and where do they sit in the loop?
- What input and output does a hook have?
- Can a hook block an action or modify its result?
- What happens when a hook stalls or fails?
Twenty-six hook events
Claude Code has far more hook locations than a basic event system might suggest. In Claude Code v2.1.220, the 26 events group naturally by lifecycle stage.
Session lifecycle
-
SessionStartβ a new session begins -
SessionEndβ a session exits -
Setupβ initial configuration -
ConfigChangeβ a configuration file changes
User input and elicitation
-
UserPromptSubmitβ before the userβs submitted prompt enters the loop -
Elicitation/ElicitationResultβ when user clarification is requested and then received
Tool lifecycle
-
PreToolUseβ before every tool execution -
PostToolUseβ after a successful tool execution -
PostToolUseFailureβ after a tool failure -
PermissionRequestβ when approval is required -
PermissionDeniedβ after approval is denied
Turn completion
-
Stopβ when the loop intends to finish because the model returned no tool use -
StopFailureβ when stop handling itself fails
Task lifecycle
-
TaskCreatedβ a task is created -
TaskCompletedβ a task completes
Context compaction
-
PreCompactβ before compaction -
PostCompactβ after compaction -
InstructionsLoadedβ after instructions such asCLAUDE.mdload
Subagents and teams
-
SubagentStartβ a subagent starts -
SubagentStopβ a subagent stops -
TeammateIdleβ a teammate becomes idle in collaborative work
Files and workspace
-
FileChangedβ a file changes outside the current action -
CwdChangedβ the working directory changes -
WorktreeCreate/WorktreeRemoveβ a worktree is created or removed
Miscellaneous
-
Notificationβ a notification is triggered
Every event maps to a specific point in the loop. Users register handlers under the event name in settings.json; Claude Code invokes them automatically when that point is reached.
Four executor types
The hooks configuration supports four ways to implement a handler.
1. command: a shell command
This is the most common form. Claude Code launches a subprocess at the hook point.
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "npx prettier --write $CLAUDE_FILE_PATHS" }]
}]
}
}
The hook receives input through environment variables such as $CLAUDE_FILE_PATHS, and communicates its decision through stdout and its exit code.
2. prompt: an LLM judgment
This executor sends a prompt to the model:
{ "type": "prompt", "prompt": "Assess whether this change introduces a security issue. Reply only YES or NO." }
It is useful when the rule is complex enough that the user would rather ask a model than write deterministic logic.
3. agent: a subagent task
An agent hook starts a full subagent:
{ "type": "agent", "agentType": "general-purpose", "prompt": "..." }
It is heavier than a prompt hook, but the subagent can execute a complete loop of its own.
4. http: a webhook
An HTTP hook sends the event to an external service:
{ "type": "http", "url": "https://internal-hooks.company.com/pre-tool-use" }
This supports cross-machine automation. For example, a security team can maintain a central policy service that evaluates PreToolUse for every Claude Code user.
Together, the executors span four levels of complexity: shell script, one-shot LLM judgment, complete subagent, and remote policy service.
Hooks can block and modify
Hooks are not merely observers. Depending on the event, they can alter loop behavior.
Blocking
A hook can return decision: "block" or exit with code 2. The loop follows a blocking branch. For example, a blocked PreToolUse prevents the tool from executing and returns an is_error tool result to the LLM.
Modifying context
A hook can return additional_context. Claude Code adds it to the tool result as an <attachment>, so the LLM sees the annotation on its next decision. This is especially useful after tool execution: a hook can inspect a result and attach an extra warning or explanation.
{
"continue": true,
"decision": "block",
"reason": "...",
"additional_context": "..."
}
continue: false ends the entire loop. The exact meaning of a block depends on its event:
| Event | Effect of a block |
|---|---|
PreToolUse |
The tool does not execute; the LLM receives an is_error result |
PostToolUse |
A hook_stopped_continuation attachment is added and the loop exits |
Stop |
The intended completion is rejected and the loop must run again |
UserPromptSubmit |
The userβs input is rejected before it enters messages
|
This is the key design insight: hooks are not simple publish-subscribe callbacks. They are programmable intervention points that can change which path the loop takes.
Synchronous, asynchronous, and rewaking hooks
Hooks are synchronous by default: the loop waits until the hook returns. The default limit is generousβTOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 minβbecause a hook may involve an LLM call or a CI trigger. If it exceeds the limit, the hook is killed and the loop proceeds.
A hook can instead be asynchronous:
{ "type": "command", "command": "...", "async": true }
-
async: trueis fire-and-forget: the loop continues immediately. -
asyncRewake: trueis more subtle. If the asynchronous hook exits with code 2, it re-wakes the LLM.
asyncRewake lets a long background action notify the conversation that it now has something worth handling. A five-minute code analysis can run without blocking the user, then automatically return control to the model when its result is ready. It is an elegant event-driven wakeup mechanism for the loop.
What if a hook fails?
An unexpected hook failureβan exit code other than 0 or 2, invalid JSON, or an exceptionβdoes not crash the loop. Claude Code follows a non_blocking_error path:
- it records the error;
- the tool continues in a pre-tool case, or its existing result is used in a post-tool case;
- the user usually sees no disruptive error.
The philosophy is that a hook is an optional enhancement, not the main path. A broken enhancement must not break the agent. The exception is an explicit, valid decision such as decision: "block" or continue: false: those decisions are honored.
Hooks and permission approval
The PermissionRequest hook from the previous article is one of the three competitors in an approval race, alongside user input and the built-in classifier.
{
"hooks": {
"PermissionRequest": [{
"hooks": [{ "type": "command", "command": "./ci-safety-check.sh" }]
}]
}
}
When approval is needed, Claude Code starts ci-safety-check.sh. The script can consult an internal policy database and quickly return allow or denyβpossibly before the user has responded in the UI. The fastest valid answer wins.
This makes approval a pluggable problem. User clicks are slow but authoritative; hooks are programmable; the classifier is fast but may be conservative. Hooks turn one fixed product behavior into an extensible policy boundary.
The generality of hooks
Permission approval answers one narrow question: may a tool call proceed? Hooks generalize the idea across 26 locations. Without modifying Claude Code source, users can:
- inject organization-wide project rules at session start;
- write an audit log before every tool call;
- back up conversation state before compaction;
- prevent the loop from stopping before tests complete;
- load different rules automatically when the working directory changes.
Each is a custom intervention on the loop itself.
Summary
- 26 hook events cover the important stages of the loop lifecycle.
-
Four executorsβ
command,prompt,agent, andhttpβrange from scripts to remote services. - Hooks can block, modify context, and force loop state transitions.
- Hooks support sync, async, and asyncRewake execution; the latter can wake an LLM after background work finishes.
- Unexpected failures are non-blocking, while explicit decisions are respected.
- Permission approval is a specialized hook use case: the
PermissionRequestevent makes it programmable.
The next article examines concrete tool execution: when several tool_use blocks arrive together, which can run in parallel, which must remain serialized, and how tool failures become model-visible results.
References
Primary implementation locations (Claude Code v2.1.220):
-
src/entrypoints/sdk/coreTypes.tsβ hook-event enumeration -
src/schemas/hooks.tsβ the four executors (command,prompt,agent,http) -
src/utils/hooks.tsβ central dispatcher,executeHooks(), andexecutePreToolHooks() -
src/services/tools/toolHooks.tsβ pre/post tool hook triggers -
src/query/stopHooks.tsβ Stop-hook loop blocking -
src/hooks/toolPermission/PermissionContext.tsβ permission-hook participation in the approval race
Further reading: Claude Code hooks documentation.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.