TL;DR A
"type": "prompt"hook sends your condition plus the event JSON to a fast model, which answers{"ok": true|false, "reason": "..."}. It covers rules no script can check. Three things to settle before you write one: the evaluator reads the conversation, not your repo; onPreToolUseyou needcontinueOnBlockor the turn dies on the first block; and unless you tell it when to returnimpossible, it can keep you in a loop.
A command hook answers with an exit code. That covers anything a script can decide: whether the file exists, whether the linter passed, whether the command matches a pattern.
What it does not cover are the rules that are a judgment call. "Don't stop if you left tests red." "Don't run that command if it destroys real data." Writing that in bash turns into a pile of grep that breaks on the first case you did not foresee. There is another hook type for this, and you may have used it already without building one: /goal is a prompt hook on Stop, packaged for one session and one case. Writing your own gives you the whole primitive: any of the thirteen events that accept it, permanent in settings.json, and four fields /goal never exposes.
How it works
Instead of spawning a process, Claude Code sends your prompt to a fast model (Haiku by default) along with the event JSON, which lands wherever you put $ARGUMENTS. The model replies with a fixed JSON shape: ok, reason and optionally impossible. One call, no tools.
That sets the boundary: the evaluator reads the conversation transcript, not your project. If the condition cannot be checked from what Claude has already surfaced in the chat, it cannot be judged. And when the conversation runs long, the transcript is trimmed to a budget and the evaluator is told to answer insufficient evidence in transcript when the part it needs fell off the front (verified in the 2.1.278 binary). Keep conditions short and measurable.
Setting one up
1. The minimum block
In ~/.claude/settings.json or the project's .claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Context: $ARGUMENTS\n\nDid it run the test suite and finish green? Answer {\"ok\": true, \"reason\": \"...\"} if the transcript proves it, or {\"ok\": false, \"reason\": \"what is missing\"} if not.",
"timeout": 30
}
]
}
]
}
}
On Stop, an ok: false hands your reason back to Claude as its next instruction and the turn carries on. Same cycle as /goal, but across every session and without restating it each time.
2. Give it a way out or you get a loop
impossible is not a config field, it is part of the model's response. The evaluator can return it on its own, but the way to get it when you need it is to ask for it in the prompt:
If the condition can never be satisfied (the file does not exist, the task does not
apply), answer {"ok": false, "reason": "...", "impossible": true}.
With impossible: true, Claude Code lets the turn end instead of feeding the reason back. Without that sentence, an unsatisfiable condition gets re-evaluated turn after turn.
3. On PreToolUse, continueOnBlock is not optional
The same hook on PreToolUse becomes a semantic guard: it judges the command before it runs. The default flips the outcome there. With continueOnBlock at false (the default), an ok: false ends the turn and the reason shows up as a warning line. With true, the reason goes back to Claude as the tool error and it keeps working, which is what you usually want:
{
"type": "prompt",
"prompt": "Proposed command: $ARGUMENTS\n\nDoes it delete or overwrite data that cannot be recovered? {\"ok\": false, \"reason\": \"...\"} if so.",
"continueOnBlock": true
}
To filter before the model is called at all, the same entry takes an if field holding a permission rule: conditional hooks.
What validation catches, and what it misses
A malformed prompt hook does not raise an error, it disappears. Ship it inside a plugin and claude plugin validate does say so, in these words:
❯ hooks: hooks.Stop.0.hooks.0: Invalid prompt hook (prompt: Invalid input);
entry ignored at runtime
What it does not catch: a prompt hook parked on an event that does not accept one. With a prompt hook placed on SessionStart, validation passes clean, no warning. The thirteen events that accept prompt are PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied, Stop, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle, UserPromptSubmit and UserPromptExpansion. On SessionStart, PreCompact or Notification you still need a command hook.
prompt or agent
If the condition needs to open files or run the test suite, the type is not prompt but agent: same shape, except it spawns a subagent with tools and up to 50 turns. It is experimental and its default timeout goes up to 60 seconds. The working rule: prompt judges what is already in the conversation, agent goes and checks.
Reference
| Field | Where | Value |
|---|---|---|
prompt |
config | Required. $ARGUMENTS receives the event JSON; leave it out and the JSON is appended |
model |
config | Optional. Defaults to the small fast model (Haiku) |
timeout |
config | Optional. 30 seconds |
continueOnBlock |
config | Optional. false by default: blocking ends the turn |
ok |
response | true allows, false blocks |
reason |
response | Required when ok is false |
impossible |
response | With ok: false, on Stop and SubagentStop it lets the turn end |
Official docs: Hooks reference