CCLearn

Handler types and timeouts — command, http, mcp_tool, prompt, agent

verified against Claude Code 2.1.261 · 2026-09-0913 min

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.

typeWhat runsHow it returns a decision
commandA shell command; the event JSON arrives on stdinExit code and stdout
httpA POST of the event JSON to a URLThe HTTP response body
mcp_toolA tool on an already-connected MCP serverThe tool's text output, read like stdout
promptOne call to a Claude modelThe model's JSON, in ok / reason form
agentA subagent that can use tools like Read, Grep and GlobThe 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}.

json
{
  "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:

ResponseOutcome
2xx, empty bodySuccess; equivalent to exit 0 with no output
2xx, JSON object bodyParsed with the standard schema; the JSON decides
2xx, plain text bodyNon-blocking error; the text is not added to Claude's context
Non-2xx statusNon-blocking error; execution continues
Connection failureNon-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 as plugin:my-plugin:database-tools. Use that name where a configured server name is expected, such as an mcp_tool hook's server field. — 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:

typeDefault timeoutLowered to
command, http, mcp_tool600s30s on UserPromptSubmit, PreModelSwitch, PostModelSwitch; 10s on MessageDisplay
prompt30s—
agent60s—

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, or mcp_tool hook 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, and continue have 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.

Synchronousasync: true
Claude waitsYesNo
Can block or denyYesNo — decision fields are inert
timeout enforcedYesNo, once it is running
Output arrivesBefore the actionNext 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 isUseBecause
A script, and the answer must gate the actioncommandDeterministic, blocks, no network
A script, and you only want to be toldcommand with async: trueReports on the next turn without stalling the loop
Owned by a service the team runshttpOne endpoint instead of a script per laptop — answer 200 to deny
Already a tool on a connected servermcp_toolNo new script; expect "not connected" on SessionStart
A judgement the input JSON containspromptA model call, 30s, no tools
A judgement needing the codebaseagentExperimental; 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

  1. You move a working PreToolUse deny from a local script to your team's policy service. The service is correct and returns 403 for anything it refuses. What happens to the tool calls it refuses, and what should it return instead?

    Show answer

    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 Forbidden from a policy service is the same fail-open shape as the exit 1 in 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.

  2. A colleague makes the ninety-second test hook async: true because it was slowing everyone down. It is a PreToolUse hook that denies writes when tests are failing. What does it enforce now?

    Show answer

    Nothing. Async hooks can't block or control Claude's behavior — decision, permissionDecision and continue have 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, and timeout stops being enforced too once the hook is running.

    What it can still do is report: additionalContext and systemMessage from 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 — an if filter, or a cadence that isn't every tool call.

  3. 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 answer

    An agent hook, 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 agent rather than prompt because 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 on PreToolUse all of them happen before your tool call does, so the default budget can cancel it mid-investigation; the docs' own example sets timeout: 120. Note also that agent and prompt don't use the permissionDecision schema at all; they answer in the smaller ok / reason form.

  4. An mcp_tool hook on SessionStart calls your memory server to load yesterday's notes, and reports "not connected" on its first run. Why — and does that failure stop the session from starting?

    Show answer

    Because the server was not connected yet, and the hook will not connect it: an mcp_tool handler requires a server that is already connected and never triggers an OAuth or connection flow. SessionStart and Setup typically 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 the server field for the naming trap: a plugin-bundled server registers under the scoped name plugin:<plugin-name>:<server-name>, and that is the name an mcp_tool hook has to use.

Sources