Hooks
Run shell commands on Autopilot lifecycle events, veto tool calls and prompts, and answer permission requests from a script.
Overview
Hooks are shell commands that Autopilot runs at fixed points in a session. They fire in every host: the terminal UI, --print, --emit-events, autopilot serve and the SDK (where you opt in). They also fire for tool calls made from inside the Code Mode sandbox.
Payloads use Claude Code's field names as well as Autopilot's own, so Claude Code hook scripts work unchanged.
Events
| Event | Fires when | Can block? |
|---|---|---|
SessionStart |
A session's first prompt, before UserPromptSubmit (source: startup or resume) |
No |
PreToolUse |
A tool call is about to run | Yes |
PostToolUse |
A tool call finished (success or failure) | No |
PostToolUseFailure |
A tool call failed (PostToolUse also fires) |
No |
PermissionRequest |
You're about to be asked to approve a call | Allow or deny |
UserPromptSubmit |
A prompt is submitted, in every mode | Yes |
Stop |
A turn ended cleanly | No |
PreCompact |
Auto-compaction is about to run | No |
SessionEnd |
Autopilot exits, or /clear (reason) |
No |
Settings location
Hooks are read from four files and merged in this order:
~/.config/kimiflare/settings.json(legacy global)~/.config/autopilot/settings.json(global).kimiflare/settings.json(legacy project).autopilot/settings.json(project)
~/.config is $XDG_CONFIG_HOME when that is set. For each event, hooks run in that order. An entry with the same id as an earlier one replaces it. The autopilot paths are canonical: /hooks enable, the hook wizard and the dashboard write new hooks there. /hooks path prints all four paths.
Schema
{
"hooks": {
"PreToolUse": [
{
"id": "no-secrets",
"matcher": "^(edit|write)$",
"command": "case \"$AUTOPILOT_HOOK_PATH\" in *.env|*.pem) echo 'blocked' >&2; exit 2;; esac"
}
],
"PostToolUse": [
{
"id": "format-ts",
"matcher": "^(edit|write)$",
"command": "npx --no-install prettier --write \"$AUTOPILOT_HOOK_PATH\" >/dev/null 2>&1 || true"
}
],
"Stop": [
{ "id": "bell", "command": "printf '\\a'" }
]
}
}
Hook fields
| Field | Required | Default | Description |
|---|---|---|---|
command |
yes | The shell command | |
matcher |
no | every tool | Regex matched against the tool name. Used by PreToolUse, PostToolUse, PostToolUseFailure and PermissionRequest. |
id |
no | derived from event and command | Stable handle for /hooks enable and /hooks disable |
enabled |
no | true |
Set false to keep a hook in config but skip it |
timeoutMs |
no | 30000 |
Hard kill if the hook hangs |
description |
no | Shown by /hooks list |
|
failClosed |
no | false |
On veto events, block when the hook itself fails |
Hook input
Each hook receives the event payload as JSON on stdin, and as AUTOPILOT_HOOK_* environment variables. Every variable is also set under its old KIMIFLARE_HOOK_* name.
AUTOPILOT_HOOK_PAYLOADholds the full JSON copy for payloads up to 32 KiB. Above that it is omitted andAUTOPILOT_HOOK_PAYLOAD_OMITTED=1is set. Prefer reading stdin.- Payloads carry Claude Code's field names:
hook_event_name,tool_name,tool_input,tool_response,tool_call_idandtranscript_path. - A successful
writeoredithas Claude Code'sstructuredPatchandoriginalFileintool_response. - When intent classification has assigned a tier, payloads on
UserPromptSubmit,PreToolUseandPostToolUseincludetier(light,mediumorheavy), also available as$AUTOPILOT_HOOK_TIER.
Matchers
matcher is tried against both Autopilot's native tool name and the Claude Code name: bash/Bash, edit/Edit, write/Write, read/Read, glob/Glob, grep/Grep, web_fetch/WebFetch, search_web/WebSearch. An edit with edits[] also answers to MultiEdit.
| Matcher | Matches |
|---|---|
bash, Bash, ^bash$ |
bash calls |
Bash|Edit, ^(edit|write)$ |
Either tool |
^mcp_ |
Every MCP tool |
*, "", or no matcher |
Every tool |
Matching is case-sensitive (BASH matches nothing) and unanchored (dit matches edit). Use ^...$ for an exact name. An invalid regex matches nothing.
Blocking and exit codes
PreToolUse and UserPromptSubmit can block. They follow Claude Code's rules:
| Hook result | Effect |
|---|---|
exit 0 |
Allowed. Stdout may carry a JSON decision. |
exit 2 |
Blocked. Stderr (or stdout if stderr is empty) is the reason shown to the model. |
| Any other non-zero exit, a timeout, command not found (127) or a spawn error | The hook failed. You get a warning and the action is allowed, unless the hook sets failClosed. |
Hooks written for older versions that blocked with exit 1 must switch to exit 2.
JSON decisions
A PreToolUse hook that exits 0 can print a decision:
{ "hookSpecificOutput": { "hookEventName": "PreToolUse",
"permissionDecision": "deny", "permissionDecisionReason": "no writes to prod config" } }
permissionDecision |
Effect |
|---|---|
deny |
Blocks, with permissionDecisionReason as the reason |
ask |
Sends the call through the normal permission prompt, even for tools that don't usually ask. Print mode denies unless --dangerously-allow-all. |
allow |
The hook doesn't object. It does not bypass your permission rules or mode. |
With several hooks, deny wins over ask, which wins over allow. The legacy {"decision": "block", "reason": "..."} form also blocks; use it for UserPromptSubmit. Stdout that isn't JSON is ignored.
Blocked prompts
A blocked UserPromptSubmit never reaches the model:
| Host | Result |
|---|---|
| Terminal UI | Shows the reason |
-p and one-shot --emit-events |
Exit code 44 |
autopilot serve |
403 prompt_blocked |
| SDK | prompt() rejects with PromptBlockedError |
PermissionRequest and PostToolUseFailure
PermissionRequest fires when Autopilot is about to ask you to approve a tool call. Plan mode, --dangerously-allow-all and config permission rules are applied first, so it never fires for calls those already settled. The payload has tool, args, tool_call_id and mode (also $AUTOPILOT_HOOK_MODE), plus Claude Code's tool_name, tool_input, tool_use_id and permission_mode.
| Hook result | Effect |
|---|---|
exit 2, or permissionDecision: "deny" |
Denied without asking you. The reason goes to the model. |
permissionDecision: "allow" |
Approved without asking you. It never overrides plan mode or a config deny rule. |
"ask", no JSON, or a hook that fails open |
The normal prompt. Print mode denies. |
Claude Code's form, {"hookSpecificOutput": {"decision": {"behavior": "allow"|"deny", "message": "..."}}}, also works.
PostToolUseFailure fires when a tool call fails: an error, a denied permission, or a bash command that exits non-zero or is killed. The payload has tool, args, tool_call_id, error and result, plus Claude Code's tool_name, tool_input, tool_use_id, error and is_interrupt. PostToolUse keeps firing for failures too, so register on only one of the two if you don't want to see a failure twice.
Hook failures and failClosed
When a hook times out, can't start, or exits non-zero (other than 2 on a veto event), Autopilot reports it:
- The terminal UI shows a warning naming the hook, the event, why it failed and what happened to the action.
--printwrites it to stderr (ahook_failedevent with--format stream-json, ahookFailuresarray withjson).- The SDK emits a
hook.failedsession event.autopilot servesends ahook.failedSSE event and lists failures underhookFailuresinGET /session/:id. - It is shown once per hook per session. Later failures are counted (
/hooks listshows[failed N× this session]). - Each failure is also an OpenTelemetry event,
autopilot.hook.failed, withhook.id,hook.event,hook.failure(timeout,spawn_failed,command_not_foundornonzero_exit),hook.reason,hook.allowed,hook.fail_closedandhook.failure_count.
For a policy hook that must never be skipped, set "failClosed": true. On PreToolUse, UserPromptSubmit and PermissionRequest, a failure then blocks the action. It has no effect on informational events.
{ "hooks": { "PreToolUse": [
{ "id": "policy", "command": "my-policy-check", "timeoutMs": 10000, "failClosed": true }
] } }
Manage hooks from the UI
/hooks # list configured hooks
/hooks recommended # list starter hooks shipped with Autopilot
/hooks enable stop-bell # enable one (writes to .autopilot/settings.json)
/hooks enable stop-bell global # or the global file
/hooks disable stop-bell
/hooks path # print all settings.json paths
/hooks reload # re-read settings after a manual edit
The recommended catalog includes terminal bells and macOS notifications on Stop, secret-file guards on PreToolUse, auto-format with Prettier on PostToolUse, and a tool-call audit log. All ship disabled.
Project trust
A repository can ship hooks (.autopilot/settings.json, .kimiflare/settings.json) and language servers (.kimiflare/lsp.json), and both run commands on your machine. Autopilot does not load a project's copies until you trust them, so starting Autopilot in a freshly cloned repository runs nothing the repository chose. Autopilot tells you at startup when a project has untrusted settings.
/trust(orautopilot trustin the project directory) lists every command the files would run./trust yestrusts exactly that content.- Trust is recorded per file as a SHA-256 of its content in
~/.config/autopilot/trusted-project-files.json. Any later change, such as agit pull, needs review again. - Hooks you add with
/hooksand LSP settings saved by/lsp configstay trusted. - For CI and other headless runs in repositories you control, set
AUTOPILOT_TRUST_PROJECT=1to trust project files for that process.
Global settings under ~/.config are always loaded.
Hooks in the SDK
SDK sessions don't run hooks unless you pass enableHooks: true to createAgentSession. SDK sessions fire SessionEnd on dispose(), or when the process exits without it. See SDK and RPC.