Skip to Content
ReferenceHook events

Hook events

A hook is a command FreeCode runs at a named moment — before a tool executes, after a turn ends, when a sub-agent starts. This page is the contract for each of those moments: what fires it, what your hook receives, what it may return, and whether returning a block actually stops anything. For the design behind it, read lifecycle hooks.

Hooks are configured under the hooks key of settings.json:

{ "hooks": { "PostToolUse": [ { "name": "format-ts", "matcher": "edit|write", "command": "pnpm prettier --write \"$CLAUDE_TOOL_INPUT\"", "timeout": 30 } ] } }

HOOK_EVENT_NAMES (hooks/types.ts:15) is the single source of truth — an event name that is not in the table below is rejected at load time with a warning.

The 14 events

toolInput is the JSON your command reads from $CLAUDE_TOOL_INPUT.

EventFirestoolNametoolInputBlocking
PreToolUsebefore a tool runsthe real tool namethe tool’s argumentsenforced — the tool never runs
PermissionRequestwhen a call needs approvalthe real tool namethe tool’s argumentsenforced — a block becomes deny
PostToolUseafter a tool succeedsthe real tool namethe tool’s argumentsignored
PostToolUseFailureafter a tool throwsthe real tool namethe tool’s argumentsignored
UserPromptSubmitbefore the prompt is sent"UserPromptSubmit"{ prompt, promptLength } — prompt capped at 30K chars, promptLength is the real lengthenforced
TurnStartbefore each turn"TurnStart"{ turnCount }ignored
TurnEndafter each turn"TurnEnd"{ turnCount, inputTokens?, outputTokens?, cacheReadInputTokens?, cacheCreationInputTokens? }ignored
SessionStartafter context collection"SessionStart"{ sessionId }ignored
Stopwhen the loop terminates"Stop"{ reason }reported, not enforced
PreCompactbefore compaction"PreCompact"{ historyLength }enforced — history is left intact
PostCompactafter compaction"PostCompact"{ success }ignored
SubagentStarta sub-agent is spawned"SubagentStart"{ name }ignored
SubagentStopa sub-agent finishes"SubagentStop"{ name }ignored
Notificationthe agent needs attention"Notification"{ message }ignored — returns void

What each event does with a successful return

EventmodifiedInput (shell ✅)modifiedOutput (shell ✅)additionalContext
PreToolUsemerged into the tool’s arguments—appended to context
PermissionRequestcarried on the result——
PostToolUse—replaces the tool outputappended
PostToolUseFailure——appended to the error the model sees
UserPromptSubmit—replaces the system promptappended
SessionStart——injected; can seed the first user message
TurnStart / TurnEnd / PostCompact / SubagentStart / SubagentStop / Stop——injected

A command hook produces modifiedOutput by printing it in the JSON stdout form (see the shell protocol), so “PostToolUse replaces the tool output” and “UserPromptSubmit rewrites the prompt” hold for settings.json hooks too. One caution on the rewrite: the prompt/$CLAUDE_TOOL_OUTPUT payloads are capped at 30K chars — check promptLength (or the [output truncated] marker) before echoing a modified copy back, or the truncation becomes the rewrite.

Sharp edges

  • PermissionRequest has four return values, and the fourth is the useful one. passthrough means no hook matched — distinct from allow, which means a hook looked and approved. Without that distinction, installing a hook that ignores a tool would silently approve it.
  • A rules deny short-circuits before hooks run. A hook cannot talk a denial into an approval, because it is never asked. Hooks come from settings.json, and settings files travel with repositories.
  • PreToolUse runs before the permission check, so it gets first look at the arguments the rules will be matched against — which is exactly why a rewrite is possible.
  • A blocked PreCompact is not a crash. The service records the token count it was blocked at and returns { success: false, blocked: true }, so the same block is not retried at the same size.
  • Matchers only apply to three events. PreToolUse, PostToolUse, and PermissionRequest filter by matcher; every other event runs all of its hooks. A matcher on a SessionStart hook does nothing — there is no tool to match.

The shell protocol

The command is spawned with bash -c (or pwsh -NoProfile -NonInteractive -Command), in the context’s cwd, with these variables added to the environment:

VariableSet when
CLAUDE_SESSION_IDalways
CLAUDE_TOOL_NAMEalways
CLAUDE_TOOL_INPUTalways — the toolInput from the table above, as JSON
CLAUDE_TOOL_OUTPUTPostToolUse — the tool’s output, capped at 30K chars
CLAUDE_CWDthe context carries a cwd
CLAUDE_AGENT_ID, CLAUDE_AGENT_TYPEinside a sub-agent

Nothing is written to your hook’s stdin. The payload is entirely in the environment, which is what makes a three-line bash script a complete implementation — there is no library to import, and the same script works from any language.

Exit codes

ExitMeaning
0continue; stdout is injected as additionalContext
2block; stderr (else stdout) becomes the reason the model is shown
anything elseblock, with the reason Exit code N

That third row is fail-closed: a typo, a missing binary, or a linter exiting 1 blocks the tool. It is the right default for a gate whose job is saying no, but a sloppy hook can wedge the agent. Two paths fail open instead — a timeout and a spawn failure resolve with an error and no block, because a hook that never answered is skipped while a hook that gave an answer is trusted.

Default timeout is 5 minutes. timeout in settings.json is in seconds.

JSON on stdout

For richer control, print a JSON object. Anything whose trimmed stdout starts with { is parsed:

{ "block": false, "modifiedInput": { "path": "src/safe.ts" }, "context": "rewrote path" }
FieldEffect
blocktrue blocks; reason is shown to the model
reasonthe block reason
modifiedInputmerged into the tool’s arguments (PreToolUse)
modifiedOutputreplaces the tool output (PostToolUse) or the system prompt (UserPromptSubmit)
contextbecomes additionalContext

Note the precedence: the exit code always wins. Any non-zero exit blocks — including a hook that printed {"block": false} and exited 1; the JSON can only contribute its reason to the block. JSON is interpreted in full only on exit 0.

Matching

Two independent filters, both in registry.ts.

matcher is tested against the tool name, in this order: * or empty matches everything → exact string → the pattern as a regex → pipe-separated alternatives ("write|edit").

if is tested against the tool arguments, parsed as Tool(pattern). The tool name must match first, then the argument values are flattened into one space-joined string and glob- or regex-matched:

"if": "bash(git *)" only git commands "if": "write(*.ts)" only TypeScript writes

Running several hooks

Matching hooks run sequentially, and the results fold like this:

  • First block wins and stops the chain — later hooks are not run.
  • Last modifiedInput wins. Two hooks rewriting the same field is a configuration mistake; last-write-wins is the predictable resolution.
  • Every additionalContext is kept, joined with newlines.

Sequential rather than parallel is deliberate: hooks touch the filesystem (formatters, linters, git), and racing them on one working tree is how files get corrupted.

Each execution publishes hook.triggered on the bus; blocks publish hook.blocked with the reason.

Other hook kinds

settings.json can only create command hooks. Two more kinds exist in-process:

KindDefined byReturns
callbackcode shipping with FreeCode (registerHook(...)){ action: "continue" }, { action: "block", reason }, or { action: "modify", modifiedInput?, modifiedOutput? }
prompta prompt + optional modelasks a model for {"decision":"allow"|"block","reason":string}; 200 max tokens, 30s default timeout. Non-JSON replies fail open

Known gaps

Found while writing this page; each is also tracked in TODO.md.

  • once is parsed but never enforced. The field flows from settings.json into the registered hook and nothing reads it.
  • hook.triggered / hook.blocked are published with a cast and are not part of the typed bus union, so no consumer gets checking on them.