Hooks
Run your own logic when CrabCode hits key events: shell commands, HTTP callbacks, LLM verifiers. Configured in settings.json.
What it is
A hook is a callback attached to a CrabCode lifecycle event. On trigger, CrabCode feeds a JSON payload to your hook via stdin (or HTTP POST); the response decides what happens next — allow, block, modify input, or feed extra context back to the model.
Four hook types:
type | Form | Good for |
|---|---|---|
command | Run a shell command | Linting, logging, blocking dangerous ops |
http | POST to a URL | Central audit, forward to a SaaS |
prompt | Evaluate a prompt with a small model | Lightweight semantic checks ("is this a secret?") |
agent | Spawn an agentic verifier | Post-task verification ("did tests actually run?") |
When you see this doc
- The "Add" page in
/hooks(read-only, points you atsettings.json) - A validation error pointing at the
hooksfield
Supported events
| Event | When |
|---|---|
SessionStart | Session boots |
SessionEnd | Session shutdown (tight cap, default 1.5s) |
Setup | Once at --init / --maintenance startup |
UserPromptSubmit | User hits Enter on input |
PreToolUse | Model wants to call a tool, before execution |
PostToolUse | Tool finished successfully |
PostToolUseFailure | Tool failed / timed out / was interrupted |
Stop / StopFailure | Model finished a turn / turn ended on API error |
Notification | System notification (e.g. waiting on user input) |
SubagentStart / SubagentStop | Subagent lifecycle |
PermissionRequest / PermissionDenied | Permission prompt / denial |
PreCompact / PostCompact | Before / after conversation compaction |
CwdChanged / FileChanged | Cwd or watched file changed |
WorktreeCreate / WorktreeRemove | git worktree created / removed |
Elicitation / ElicitationResult | MCP elicitation flow |
TeammateIdle / TaskCreated / TaskCompleted | Multi-agent collaboration |
ConfigChange / InstructionsLoaded | Settings change / CRABCODE.md load |
The authoritative list is the dropdown inside
/hooks— SDK and runtime share the same source.
Example config
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.crabcode/hooks/check-bash.sh",
"if": "Bash(git push *)",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "prettier --write \"$FILE_PATH\"" }
]
}
],
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that tests ran and passed; otherwise raise an error.",
"timeout": 60
}
]
}
]
}
}{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.crabcode/hooks/check-bash.sh",
"if": "Bash(git push *)",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "prettier --write \"$FILE_PATH\"" }
]
}
],
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that tests ran and passed; otherwise raise an error.",
"timeout": 60
}
]
}
]
}
}matcher— string / regex matching event-relevant values (tool name for PreToolUse / PostToolUse). Omit to match allif— permission-rule syntax (e.g.Bash(git push *)); non-matching tool calls skip spawn entirely, saving fork costtimeout— per-execution seconds; killed on timeoutshell—commandtype only:bashorpowershell(defaults tobash, follows$SHELL)
Hook protocol (command type)
- stdin — JSON object with event-specific fields. Common keys:
tool_name,tool_input,tool_use_id(PreToolUse / PostToolUse / PostToolUseFailure)session_id,hook_event_name- PostToolUse also has
response; PostToolUseFailure addserror,error_type,is_interrupt,is_timeout - PreCompact carries compaction details; UserPromptSubmit carries the raw prompt text
- stdout — optional JSON response. Common shapes:
{"continue": false, "stopReason": "..."}— terminate this turn{"decision": "block", "reason": "..."}— block the action{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..."}}— explicit permission denial{"async": true, "asyncTimeout": 30}— switch to background async mode
- Exit codes (semantics vary per event; the
/hooksper-event help is authoritative):0— pass; stdout shown or hidden depending on event2— block / feed stderr back to the model; used by PreToolUse, UserPromptSubmit, Stop- other — show stderr to the user, conversation continues
HTTP hooks POST the same JSON to
url;headersaccepts$VAR_NAMEplaceholders resolved against theallowedEnvVarsallow list.
Full stdin / stdout schema
stdin common fields (every event): session_id, hook_event_name, cwd, transcript_path (path to the current session transcript).
Event-specific fields:
PreToolUse/PostToolUse/PostToolUseFailure:tool_name,tool_input,tool_use_id;PostToolUseaddsresponse;PostToolUseFailureaddserror,error_type,is_interrupt,is_timeoutUserPromptSubmit:prompt(raw user input)PreCompact/PostCompact:trigger,custom_instructions, ...FileChanged:changed_paths,change_kindPermissionRequest: the pending tool / permission ruleInstructionsLoaded:file_path,memory_type(User / Project / Local / Managed),load_reason(session_start / nested_traversal / path_glob_match / include / compact), optionalglobs,trigger_file_path,parent_file_path
stdout response fields (all optional; no output = default pass): continue (false ends this turn), suppressOutput (hide stdout), stopReason, decision ("approve" / "block"), reason, systemMessage (user-side warning), async + asyncTimeout (background mode), hookSpecificOutput (event-typed advanced controls).
hookSpecificOutput shapes:
PreToolUse:permissionDecision(allow/deny/ask),permissionDecisionReason,updatedInput(rewrite tool args),additionalContextUserPromptSubmit/SessionStart/Setup/SubagentStart/PostToolUse/PostToolUseFailure/Notification:additionalContextSessionStart/CwdChanged/FileChanged:watchPaths(register absolute paths to watch)PermissionRequest:decision: { behavior: "allow" | "deny", ... }Elicitation/ElicitationResult:action(accept/decline/cancel) +contentWorktreeCreate:worktreePath
Failure behavior
CrabCode treats hook failures as fail-soft:
- Exit 0 → pass
- Exit 2 → block / feed stderr back to the model (
PreToolUse,UserPromptSubmit,Stop,TeammateIdle,TaskCreated,TaskCompleted, ...) - Any other non-zero → marked
non_blocking_error: the user sees a system notice (command name + stderr), session continues - Timeout → process killed, treated as non-zero
- Bad JSON output → also
non_blocking_errorwith a schema-error notice
Only exit code 2 (or an explicit {"decision":"block"} / permissionDecision:"deny") actually stops the model. Everything else just logs and proceeds.
Debugging
- Enable debug logs: launch CrabCode with
--debug. Hook execution details (command line, stdout, stderr, exit code, duration) land in~/.crabcode/debug/<session-id>.txt. Override the directory withCRABCODE_DEBUG_LOGS_DIR; pass-d2e/--debug-to-stderrto stream to stderr directly - Latest run:
~/.crabcode/debug/latestis a symlink to the most recent debug log - Inspect stdin from the hook's POV: in your script,
cat > /tmp/hook-input.jsonthenexit 0to capture the payload for offline analysis - Hung hooks: per-hook cap is
TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 min;SessionEnddefault is 1.5s (override viaCRABCODE_SESSIONEND_HOOKS_TIMEOUT_MS). On timeout the process is killed - Debugging HTTP hooks: point
urlathttpbin.org/postor a local listener to verify the payload shape;$VAR_NAMEplaceholders inheadersonly resolve against theallowedEnvVarsallow list
Real-world examples
Example 1: block git push --force to main — a PreToolUse hook reads stdin, pulls tool_input.command, and exit 2s on a force-push match:
# ~/.crabcode/hooks/guard-force-push.sh
#!/usr/bin/env bash
payload=$(cat)
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -Eq 'git push.*(--force|-f)\s.*(main|master)'; then
echo "Refusing force-push to main/master" >&2
exit 2
fi# ~/.crabcode/hooks/guard-force-push.sh
#!/usr/bin/env bash
payload=$(cat)
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -Eq 'git push.*(--force|-f)\s.*(main|master)'; then
echo "Refusing force-push to main/master" >&2
exit 2
fiPaired with settings.json:
{ "hooks": { "PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "command": "~/.crabcode/hooks/guard-force-push.sh", "timeout": 3 }
]}
]}}{ "hooks": { "PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "command": "~/.crabcode/hooks/guard-force-push.sh", "timeout": 3 }
]}
]}}Example 2: auto-format after write — PostToolUse matches Edit|Write, grabs the freshly-written tool_input.file_path, feeds it to prettier:
{ "hooks": { "PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command",
"command": "FILE=$(jq -r '.tool_input.file_path'); [ -n \"$FILE\" ] && prettier --write \"$FILE\" 2>&1 || true",
"timeout": 10 }
]}
]}}{ "hooks": { "PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command",
"command": "FILE=$(jq -r '.tool_input.file_path'); [ -n \"$FILE\" ] && prettier --write \"$FILE\" 2>&1 || true",
"timeout": 10 }
]}
]}}Limits and caveats
- PreToolUse default timeout 10 min — but the session blocks while it runs. Keep hooks light
- Prefer absolute paths:
~doesn't expand in some envs - Hooks run as your shell user: full local exec privileges — trust the script
- PostToolUseFailure is your cleanup net: API errors / tool timeouts / Esc interrupts all land here — good place to delete temp files or
git stash pop - async / asyncRewake: set
"async": trueto run in the background without blocking;asyncRewakewakes the model on exit code 2