Handler types and timeouts — command, http, mcp_tool, prompt, agent
By the end you can
- Pick between command, http, mcp_tool, prompt and agent handlers
- State each handler type's default timeout and what a timeout decides
- Explain why an HTTP hook cannot block with a status code alone
- Decide when async is safe and when it disarms a gate
- Check which events accept the handler type you chose
Your gate works. It is on PreToolUse, the matcher matches, and it exits 2 with
a reason. Then the requirement grows: the check belongs to the team's audit
service, not your laptop. Or the rule is a judgement — "does this migration have
a rollback" — that no grep decides. Or the check takes ninety seconds and you
do not want to sit through it on every write.
Each of those pushes you off type: "command", and each of the alternatives
trades away something the command hook gave you for free. This lesson is what
each one costs.
Five handlers, one contract
Hooks are user-defined shell commands, HTTP endpoints, MCP tool calls, LLM prompts, or subagents that execute automatically at specific points in Claude Code's lifecycle. — Hooks reference
The type field picks one of five. For command, http and mcp_tool,
everything Lesson 9.2 established about decisions still holds — the same JSON
output schema, the same permissionDecision values — and only the route the JSON
travels differs. prompt and agent do not use that schema at all; they answer
in a smaller one, covered below.
type | What runs | How it returns a decision |
|---|---|---|
command | A shell command; the event JSON arrives on stdin | Exit code and stdout |
http | A POST of the event JSON to a URL | The HTTP response body |
mcp_tool | A tool on an already-connected MCP server | The tool's text output, read like stdout |
prompt | One call to a Claude model | The model's JSON, in ok / reason form |
agent | A subagent that can use tools like Read, Grep and Glob | The same ok / reason form, after tool use |
Five fields are common to every type: type, the if filter from Lesson 9.1,
timeout, statusMessage for the spinner text, and once, which Claude Code
honours only for hooks declared in skill frontmatter and ignores in settings files
and agent frontmatter.
Not every event takes every type. Thirteen events accept all five, including
PreToolUse, PostToolUse, Stop and UserPromptSubmit. Eighteen others —
FileChanged, PreCompact, Notification, SessionEnd and the rest — accept
command, http and mcp_tool but not prompt or agent. And
"SessionStart and Setup support command and mcp_tool hooks. They don't
support http, prompt, or agent hooks." Check that list before you commit to
a type. The docs say which pairings are supported; they do not say what an
unsupported one does, which is reason enough not to find out on a gate you are
relying on.
command: the default, and the one to prefer
Deterministic, no network, no model, and the only type that can run in the background. It also comes with a warning the docs put in a box:
Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration. — Hooks reference
That is the reason a .claude/settings.json you cloned is a thing to read, not
just a thing to have.
The one structural choice inside a command hook is whether you set args. With
args, Claude Code resolves command as an executable and spawns it directly —
no shell, so each argument is passed exactly as written and a path containing a
space or a $ survives. Without args, the string goes to a shell, which gives
you pipes and && and also gives you quoting bugs. Set args whenever the hook
references a path placeholder like ${CLAUDE_PROJECT_DIR}.
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/format.js", "--fix"]
}The documented list of practices for writing hooks is short: validate and sanitize
inputs, quote every shell variable as "$VAR", check for .. to block path
traversal, use absolute paths, and skip .env, .git/ and keys. The absolute-path
item splits by form — in exec form the path needs no quoting, in shell form you
wrap it in double quotes.
http: blocking needs a 2xx body, not a status code
An HTTP hook POSTs the same JSON a command hook gets on stdin, and reads the response body with the same schema. It is the right shape when the logic belongs to a service — a shared audit endpoint that sees tool use across a team, rather than a script each engineer has to install.
The failure mapping is where the intuition breaks:
| Response | Outcome |
|---|---|
| 2xx, empty body | Success; equivalent to exit 0 with no output |
| 2xx, JSON object body | Parsed with the standard schema; the JSON decides |
| 2xx, plain text body | Non-blocking error; the text is not added to Claude's context |
| Non-2xx status | Non-blocking error; execution continues |
| Connection failure | Non-blocking error; execution continues |
Two operational consequences: your endpoint being down is silence rather than a
gate, and header secrets need declaring — values interpolate $VAR_NAME only for
variables named in allowedEnvVars, and unlisted references resolve to empty
strings.
mcp_tool: the server has to be connected already
An mcp_tool handler calls a tool on a server you configured, passing arguments
built from the event JSON — "${tool_input.file_path}" substitutes the path of
the file being written. Its text output is read exactly like command-hook stdout,
so the start-with-{ rule from Lesson 9.2 applies unchanged.
The constraint is in the field description:
The server must already be connected; the hook never triggers an OAuth or connection flow. — Hooks reference
If the server is not connected, or the tool returns isError: true, you get a
non-blocking error and execution continues. Which makes the timing note that
follows it a real trap: "SessionStart and Setup typically fire before
servers finish connecting, so hooks on those events should expect the 'not
connected' error on first run." An mcp_tool hook that loads context at session
start is racing the connection it depends on.
One naming detail, because it is the same mistake in a different place from the matcher trap in Lesson 9.1: a plugin-bundled server does not register under its bare key.
The server itself registers under the scoped name
plugin:<plugin-name>:<server-name>, such asplugin:my-plugin:database-tools. Use that name where a configured server name is expected, such as anmcp_toolhook'sserverfield. — MCP
prompt and agent: putting a model in the gate
Both send the hook input to a model instead of to your code. A prompt hook is
one call — "send the hook input and your prompt to a Claude model, Haiku by
default" — and an agent hook spawns a subagent that can read files and grep
before deciding. Use $ARGUMENTS in the prompt text as the placeholder for the
hook's JSON input.
Neither uses the permissionDecision schema. They answer in a smaller one:
ok, reason, and for prompt hooks impossible. ok: true allows. What
ok: false does depends on the event, and the two rows worth memorising are
Stop — the reason is fed back to Claude as its next instruction and the turn
continues, unless the response also sets impossible: true, which lets the stop
through — and PreToolUse, where the tool call is denied.
Lesson 8.5 built a writer-and-reviewer pair by hand and warned that a reviewer with no bounded brief invents findings. An agent hook is that reviewer wired into the lifecycle, with the same weakness and one new one: it is the only handler type the docs tell you not to ship.
Agent hooks are experimental. Behavior and configuration may change in future releases. For production workflows, prefer command hooks. — Hooks reference
It is also the only handler that runs a loop: an agent hook gets "up to 50
tool-use turns" before it returns its decision, and on a PreToolUse hook every
one of those turns happens before your tool call does. The docs' own division of
labour is the right default — a prompt hook when the hook input alone
decides, an agent hook when the answer needs the state of the codebase, and a
command hook whenever a script could have told you.
Timeouts, and what a timed-out hook decides
Defaults are per type, and four events lower them:
type | Default timeout | Lowered to |
|---|---|---|
command, http, mcp_tool | 600s | 30s on UserPromptSubmit, PreModelSwitch, PostModelSwitch; 10s on MessageDisplay |
prompt | 30s | — |
agent | 60s | — |
Two of those four events are recent arrivals: PreModelSwitch and
PostModelSwitch both require v2.1.251 or later, so on an older build there
are two lowered budgets rather than four, and a hook registered on either
model-switch event is configuration that never fires at all.
SessionEnd hooks share a 1.5-second budget between them; a longer
per-hook timeout in settings raises the budget to match, up to 60 seconds.
The agent row and the fifty turns are worth reading together: up to fifty turns
of tool use, inside a sixty-second default, is a subagent that can be cancelled
mid-investigation — the timeout: 120 in the docs' own example is not decoration.
And here is the part that decides whether your gate is a gate:
A timed-out
command,http, ormcp_toolhook doesn't block the tool call. The call continues through the normal permission flow, so don't count on a stalled hook to act as a gate. — Hooks reference
Claude Code cancels a command, http or mcp_tool hook that reaches its
timeout and discards its output, so on most events a timed-out hook of those
three types renders no decision at all. Add it to the list from Lesson 9.2:
exit 1, a mistyped path, a script that is not executable, an HTTP 403, an
unconnected MCP server, and now a slow endpoint all end the same way — the action
happens. PreModelSwitch is a documented exception, and the direction it runs
in is the point: a hook there that does not answer before its timeout blocks the
switch, where the identical silence on a command, http or mcp_tool hook on
PreToolUse lets the tool call through. That exception arrived with the event
itself, in v2.1.251. WorktreeCreate gets there by a different route: its
command hook returns the new worktree's path on stdout, a cancelled hook returns
none, and a missing path fails the creation.
async: true trades control for speed
Ninety seconds of tests on every write is unusable synchronously, so there is a
background mode — on command hooks only.
Async hooks can't block or control Claude's behavior: response fields like
decision,permissionDecision, andcontinuehave no effect, because the action they would have controlled has already completed. — Hooks reference
Not "weakened". No effect. An async PreToolUse hook returning
permissionDecision: "deny" is a settings file that reads like a policy and
enforces nothing.
What an async hook can do is report. Claude Code delivers additionalContext
and systemMessage from its JSON on the next conversation turn — which is the
right shape for "the test suite you kicked off ten seconds ago has failed", and
the wrong shape for anything you need before the next tool call.
| Synchronous | async: true | |
|---|---|---|
| Claude waits | Yes | No |
| Can block or deny | Yes | No — decision fields are inert |
timeout enforced | Yes | No, once it is running |
| Output arrives | Before the action | Next conversation turn, or the next user interaction if idle |
| Repeat firings | — | Each one is a separate process; no deduplication |
Two edges. An asyncRewake hook exiting 2 wakes Claude immediately even when the
session is idle, and it does keep its timeout. And under claude -p, Claude Code
kills any async hook still running at teardown and finalizes it as cancelled —
so background work that must outlive a scripted run has to detach itself.
Choosing
Work down this list and stop at the first row that fits.
| The check is | Use | Because |
|---|---|---|
| A script, and the answer must gate the action | command | Deterministic, blocks, no network |
| A script, and you only want to be told | command with async: true | Reports on the next turn without stalling the loop |
| Owned by a service the team runs | http | One endpoint instead of a script per laptop — answer 200 to deny |
| Already a tool on a connected server | mcp_tool | No new script; expect "not connected" on SessionStart |
| A judgement the input JSON contains | prompt | A model call, 30s, no tools |
| A judgement needing the codebase | agent | Experimental; up to 50 turns; not for production |
The through-line of Lessons 9.2 and 9.3 is one sentence: almost every way a
handler can fail to answer is a way the action proceeds. You have met two of
the documented exceptions — WorktreeCreate, which fails on any non-zero exit,
and PreModelSwitch, where a hook cancelled at its timeout blocks the switch
instead of waving it through. Check the event's own row before you assume a
third. Everywhere else, blocking has to be earned, and it is earned by a handler
that finishes, in time, with a decision. Choosing a type is choosing how many
ways it can fail to do that.
Checkpoint
-
You move a working
PreToolUsedeny from a local script to your team's policy service. The service is correct and returns403for anything it refuses. What happens to the tool calls it refuses, and what should it return instead?Show answerAnswer
They all go through. HTTP hooks can't signal a blocking error through status codes alone: a non-2xx response is a non-blocking error and execution continues, so a
403 Forbiddenfrom a policy service is the same fail-open shape as theexit 1in Lesson 9.2, wearing an HTTP status code instead.To refuse, the service has to answer 200 with a JSON body carrying the decision fields —
permissionDecision: "deny"— which is parsed with the same schema a command hook's stdout uses. Two more rows of that table are worth carrying to the same service: a 2xx with a plain-text body is also a non-blocking error and the text is not added to Claude's context, and a connection failure is one too, so the endpoint being down is silence rather than a gate. -
A colleague makes the ninety-second test hook
async: truebecause it was slowing everyone down. It is aPreToolUsehook that denies writes when tests are failing. What does it enforce now?Show answerAnswer
Nothing. Async hooks can't block or control Claude's behavior —
decision,permissionDecisionandcontinuehave no effect, because the action they would have controlled has already completed. Not weakened: inert. The settings file still reads like a policy and enforces none of it, andtimeoutstops being enforced too once the hook is running.What it can still do is report:
additionalContextandsystemMessagefrom its JSON are delivered on the next conversation turn. That is the right shape for "the suite you kicked off ten seconds ago has failed" and the wrong shape for anything you need before the next tool call. If the check has to gate, it stays synchronous and you cut what it costs another way — aniffilter, or a cadence that isn't every tool call. -
You want a hook that reads the diff and decides whether a migration has a rollback. Name the handler type you would use, its default timeout, and the caveat the docs attach to shipping it.
Show answerAnswer
An
agenthook, default timeout 60s, and the docs say agent hooks are experimental — behavior and configuration may change in future releases, and for production workflows prefer command hooks. It is the only handler type the docs tell you not to ship.It is
agentrather thanpromptbecause the judgement needs the state of the codebase, not just the hook input: an agent hook can use tools like Read, Grep and Glob before deciding, where a prompt hook is one model call with no tools. Read the 60 seconds against the other number — an agent hook gets up to 50 tool-use turns before it returns its decision, and onPreToolUseall of them happen before your tool call does, so the default budget can cancel it mid-investigation; the docs' own example setstimeout: 120. Note also thatagentandpromptdon't use thepermissionDecisionschema at all; they answer in the smallerok/reasonform. -
An
mcp_toolhook onSessionStartcalls yourmemoryserver to load yesterday's notes, and reports "not connected" on its first run. Why — and does that failure stop the session from starting?Show answerAnswer
Because the server was not connected yet, and the hook will not connect it: an
mcp_toolhandler requires a server that is already connected and never triggers an OAuth or connection flow.SessionStartandSetuptypically fire before servers finish connecting, so hooks on those events should expect the "not connected" error on first run — the hook is racing the connection it depends on.No, the session still starts. An unconnected server, like a tool that returns
isError: true, is a non-blocking error and execution continues. If it keeps failing after the servers are up, check theserverfield for the naming trap: a plugin-bundled server registers under the scoped nameplugin:<plugin-name>:<server-name>, and that is the name anmcp_toolhook has to use.