Hooks
Run your own commands, HTTP calls or model checks at fixed points in the agent loop, with the hook events and JSON protocol your existing hooks already use.
Hooks let you attach your own logic to the agent loop: format a file after every edit, block a command your team never wants run, add context when a session starts, or keep the agent working until the tests pass. Unlike instructions, hooks always run. They don't depend on the model remembering to do something.
Cusp's hooks use the widely used hook format: the same events, configuration, exit codes and JSON output. Hooks you already have in .claude/settings.json work in Cusp.
Configuration
Hooks live under hooks in any settings file. Each event maps to a list of matcher groups, and each group lists handlers.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write|ApplyPatch",
"hooks": [
{ "type": "command", "command": "bunx biome format --write \"$CUSP_PROJECT_DIR\"", "timeout": 30 }
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.cusp/hooks/check-bash.sh" }
]
}
]
}
}Hooks from every settings level are combined: managed, local, project and user, plus hooks declared by skills and subagents while they're active.
Matchers
matcher selects which tool calls (or which event subtypes) the group applies to.
- A bare name matches exactly:
Bash. - A list separated by
|matches any of them:Edit|Write. - Anything with regex characters is treated as a regular expression:
mcp__github__.*. - An empty or missing matcher matches everything.
Matchers see canonical tool names. A GPT model's apply_patch call arrives as ApplyPatch, so include it when you match edits.
Narrowing with if
A handler can carry an if condition written as a single permission rule. It only runs when the call matches:
{ "type": "command", "command": "./scripts/guard-push.sh", "if": "Bash(git push *)" }if is evaluated only on tool events.
Handler types
| Type | What it does | Key fields |
|---|---|---|
command | Runs a shell command. The event's JSON input arrives on stdin | command, args (exec form without a shell), shell, async, timeout, onFailure |
http | POSTs the JSON input to a URL and reads the response body as output | url, headers, allowedEnvVars, timeout, onFailure |
mcp_tool | Calls a tool on a connected MCP server | server, tool, input |
prompt | Asks a model a yes/no question about the event. $ARGUMENTS is replaced by the JSON input | prompt, model |
agent | Like prompt, but the model can use tools to investigate before answering | prompt, model |
Common fields on every handler: timeout (seconds), statusMessage (shown while it runs), if, and once (run once, then remove; honored for skill hooks).
onFailure: "block" makes a timeout or crash count as a block. By default a failing hook doesn't stop the agent.
Input
Every hook receives a JSON object. Common fields:
| Field | Meaning |
|---|---|
session_id | The session |
transcript_path | Path to the session transcript, when available |
cwd | The current working directory |
permission_mode | The session's permission mode |
hook_event_name | The event, such as PreToolUse |
Tool events add tool_name, tool_input and tool_use_id. PostToolUse adds tool_response.
Environment variables for command hooks:
| Variable | Value |
|---|---|
CUSP_PROJECT_DIR, CLAUDE_PROJECT_DIR | The project root. The hook's working directory |
CUSP_HOOK_EVENT | The event name |
CUSP_ENV_FILE, CLAUDE_ENV_FILE | For SessionStart, Setup, CwdChanged and FileChanged: a file where the hook can write export NAME=value lines to set environment variables for later shell commands |
Output
Exit codes
| Exit code | Meaning |
|---|---|
0 | Success. If stdout is JSON, it's read as a decision (below). Otherwise stdout may be added as context for some events |
2 | Block. stderr is the reason. What "block" means depends on the event: a PreToolUse block stops the call and tells the model why; a Stop block keeps the agent working |
| Anything else | A non-blocking error. The agent continues; the error is shown to you |
JSON output
Print a JSON object on stdout with exit code 0 for finer control:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use the deploy skill instead of calling kubectl directly."
}
}| Field | Events | Effect |
|---|---|---|
continue: false, stopReason | Any | Stop the agent loop entirely |
decision: "block", reason | UserPromptSubmit, PostToolUse, Stop, SubagentStop, … | Block, with the reason fed back |
systemMessage | Any | A notice shown to you |
hookSpecificOutput.additionalContext | SessionStart, UserPromptSubmit, PostToolUse, … | Text added to the model's context |
hookSpecificOutput.permissionDecision | PreToolUse | allow, deny, ask or defer |
hookSpecificOutput.updatedInput | PreToolUse | Replace the tool's input before it runs |
hookSpecificOutput.decision | PermissionRequest | Answer the permission prompt: { "behavior": "allow" } or deny |
When several hooks answer the same event, the strictest wins: for PreToolUse, deny beats defer, which beats ask, which beats allow. A hook's deny holds in every permission mode, including bypass. Context from multiple hooks is combined in configuration order. Each text field is capped at 10,000 characters.
Events
| Event | When it runs | Can block or decide |
|---|---|---|
SessionStart | A session starts or resumes | Adds context |
Setup | One-time repository setup or maintenance runs | — |
InstructionsLoaded | An instruction file or rule is loaded | — |
UserPromptSubmit | Before your prompt is sent to the model | Block the prompt, add context |
UserPromptExpansion | A skill or command expands into a prompt | Block |
MessageDisplay | Before an assistant message is shown | Replace the displayed text |
PreToolUse | Before a tool runs | Allow, deny, ask, defer; rewrite input |
PermissionRequest | A permission prompt is about to be shown | Answer it |
PermissionDenied | Auto mode denied a call | Ask for a retry |
PostToolUse | After a tool succeeds | Add context, replace output, block |
PostToolUseFailure | After a tool fails | Add context |
PostToolBatch | After a batch of parallel tool calls finishes | Add context |
Notification | Cusp sends you a notification, for example when it needs input | — |
SubagentStart | A subagent starts | Adds context |
SubagentStop | A subagent finishes | Block to keep it working |
TaskCreated, TaskCompleted | A task-list item is created or completed | — |
Stop | The agent finishes its turn | Block to keep it working |
StopFailure | The turn ended because of an error | — |
TeammateIdle | A teammate agent becomes idle | — |
ConfigChange | A settings file changes | — |
CwdChanged | The working directory changes | Set environment |
DirectoryAdded | A directory is added to the workspace | — |
FileChanged | A watched file changes on disk | Set environment |
WorktreeCreate, WorktreeRemove | A git worktree is created or removed | WorktreeCreate can supply the path |
PreCompact, PostCompact | Before and after compaction | — |
PreModelSwitch, PostModelSwitch | Around a model change | PreModelSwitch can deny it |
Elicitation, ElicitationResult | An MCP server asks you for input, and after you answer | Accept or decline |
SessionEnd | The session ends | — |
Examples
Block edits to generated files
#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path // empty')
if [[ "$path" == *"/generated/"* ]]; then
echo "Files under generated/ are produced by 'bun run codegen'. Edit the schema instead." >&2
exit 2
fi{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|MultiEdit|Write|ApplyPatch|EditLines",
"hooks": [{ "type": "command", "command": "${CUSP_PROJECT_DIR}/.cusp/hooks/no-generated.sh" }]
}
]
}
}Keep going until tests pass
A Stop hook that blocks with the test output sends the agent back to work:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "out=$(bun test 2>&1) || { echo \"$out\" | tail -40 >&2; exit 2; }", "timeout": 300 }
]
}
]
}
}The hook exits 0 when the tests pass, so the turn ends normally. If a failure is outside the agent's control, it will keep trying until you interrupt it; combine this with --max-turns in headless runs.
Add context at session start
{
"hooks": {
"SessionStart": [
{
"hooks": [{ "type": "command", "command": "echo \"Open issues assigned to me:\"; gh issue list --assignee @me --limit 5" }]
}
]
}
}Ask a model to check the work
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "The user asked for a change and the agent says it is done. Based on this transcript excerpt, did the agent run the tests and do they pass? $ARGUMENTS"
}
]
}
]
}
}Safety
- Hooks run with your user's permissions, outside the sandbox. Review hook scripts in a repository before trusting it.
- In an untrusted workspace, project and local settings can't add hooks.
- Administrators can restrict hooks to managed settings with
allowManagedHooksOnly, and restrict HTTP hooks to approved URLs withallowedHttpHookUrls. disableAllHooks: trueturns hooks off.