Docs / Autopilot / Hooks

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:

  1. ~/.config/kimiflare/settings.json (legacy global)
  2. ~/.config/autopilot/settings.json (global)
  3. .kimiflare/settings.json (legacy project)
  4. .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_PAYLOAD holds the full JSON copy for payloads up to 32 KiB. Above that it is omitted and AUTOPILOT_HOOK_PAYLOAD_OMITTED=1 is set. Prefer reading stdin.
  • Payloads carry Claude Code's field names: hook_event_name, tool_name, tool_input, tool_response, tool_call_id and transcript_path.
  • A successful write or edit has Claude Code's structuredPatch and originalFile in tool_response.
  • When intent classification has assigned a tier, payloads on UserPromptSubmit, PreToolUse and PostToolUse include tier (light, medium or heavy), 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.
  • --print writes it to stderr (a hook_failed event with --format stream-json, a hookFailures array with json).
  • The SDK emits a hook.failed session event. autopilot serve sends a hook.failed SSE event and lists failures under hookFailures in GET /session/:id.
  • It is shown once per hook per session. Later failures are counted (/hooks list shows [failed N× this session]).
  • Each failure is also an OpenTelemetry event, autopilot.hook.failed, with hook.id, hook.event, hook.failure (timeout, spawn_failed, command_not_found or nonzero_exit), hook.reason, hook.allowed, hook.fail_closed and hook.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 (or autopilot trust in the project directory) lists every command the files would run. /trust yes trusts 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 a git pull, needs review again.
  • Hooks you add with /hooks and LSP settings saved by /lsp config stay trusted.
  • For CI and other headless runs in repositories you control, set AUTOPILOT_TRUST_PROJECT=1 to 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.