Skip to content
Dev edited this page Sep 7, 2026 · 2 revisions

English · 中文

Lifecycle hooks

Tracks v0.12.0 (3a762b1). Runtime in src/agent/hook.rs; the eleven call sites are in src/agent/loop_.rs; config loading in src/settings.rs and src/config.rs. If this page disagrees with those files, the repo wins. Re-verify with: cargo test hook

⚠ Four config keys were RENAMED in v0.12, and the old ones are hard errors

If you wrote a hook config against nanopi v0.11 or against this page before v0.12, it will not load. There is no alias and no deprecation period:

Retired key Write this instead
[[hooks.pre_tool_use]] [[hooks.tool_execution_start]]
[[hooks.post_tool_use]] [[hooks.tool_execution_end]]
[[hooks.user_prompt_submit]] [[hooks.input]]
[[hooks.session_end]] [[hooks.session_shutdown]]

See Renamed in v0.12 for what the error looks like and why it is an error rather than a warning.


Mental model

A hook is a shell command, not a plugin. nanopi spawns bash -c <command>, writes one line of JSON to its stdin, and reads back an exit code plus stdout. That is the entire contract — any language that can read stdin and exit works.

Two things follow from that:

Hooks are policy, not code. They live in config files, they concatenate rather than override, and --no-hooks turns all of them off at once.

Hooks and WASM plugins are no longer two disjoint mechanisms. Since v0.12 a plugin can observe these same eleven events, under a separate config grant, and receives byte-identical payloads. What it cannot do is veto or rewrite one — that is still a hook's privilege alone. See Plugin-Events.

So: if you want to stop or rewrite something, write a hook. If you want to react in-process, in a sandbox, with state that survives a restart, subscribe a plugin.


One vocabulary: PI's names

Before v0.12, four of these events carried Claude Code's names and seven carried PI's. v0.12 made all eleven PI's names, on every surface at once — the [[hooks.*]] config key, the JSON payload's event field, the NANOPI_EVENT environment variable, and a plugin's events grant. There is no translation table to consult, because there is nothing left to translate.

The eleven events

Event Config key / payload event Fires matcher matched against Block Transform
SessionStart session_start once, after the agent is constructed, before the first turn session id advisory no
SessionShutdown session_shutdown once, before the process exits — and on /new, /resume, /fork, /import session id advisory no
BeforeAgentStart before_agent_start top of each turn, before the user message enters context turn count yes yes (prompt)
Input input right after BeforeAgentStart, before /skill: expansion the empty string yes yes (prompt)
TurnStart turn_start top of every agent-loop iteration turn count ignored ignored
ToolExecutionStart tool_execution_start before each tool call executes tool name yes yes (whole arg object)
ToolExecutionEnd tool_execution_end after the tool returns, before the result is rendered or seen by the model tool name ignored yes (content / is_error)
TurnEnd turn_end bottom of every agent-loop iteration turn count ignored ignored
MessageEnd message_end once, after the loop ends, before post-turn compaction turn count ignored ignored
SessionBeforeCompact session_before_compact inside compact_now, before compaction runs compaction reason advisory no
SessionCompact session_compact after compaction succeeds compaction reason advisory no

The first column is the CamelCase form, which is what NANOPI_EVENT carries. The second is the snake_case form, which is the config key, the payload's event value, and the name a plugin uses in its events grant — one string, three surfaces.

"advisory" means the outcome is discarded by design — a session always starts, ends, and compacts. "ignored" means the same thing at a call site that could have enforced it: TurnStart, TurnEnd, MessageEnd and ToolExecutionEnd throw away the block decision. A {"decision":"block"} from one of those is not an error and not a warning; it simply has no effect. Do not build a policy on it.

Since v0.11.1 an advisory block is at least reported: nanopi prints

nanopi: TurnStart hook asked to block; ignored (advisory event) [reason=...]

so a hook that blocks is no longer indistinguishable from one that ran clean.

Only three events can actually stop something: before_agent_start and input (abort the turn) and tool_execution_start (refuse the call).

The two prompt hooks chain

before_agent_start runs first and its rewritten prompt becomes the input to input, whose output is what finally enters context. Both run before /skill:name expansion, so a hook may rewrite a prompt into a slash command and it will still expand.

Blocking before_agent_start ends the turn with a bracketed marker:

[BeforeAgentStart hook blocked the turn: <reason>]

Blocking input is worded as a policy outcome, not a failure, and deliberately not bracketed (both renderers already wrap it in [error: …]):

your `input` hook refused this prompt (policy, not a failure) — <reason>

That same prose is what --output json and the session record carry, so a scripted caller and the screen agree.

matcher on non-tool events

There is no tool name outside tool_execution_start / tool_execution_end, so the matcher is applied to whatever that event has instead — and that is not always obvious:

  • session events → the session id (useful with a prefix regex)
  • compaction events → the reason string, either threshold or manual
  • before_agent_start / turn_start / turn_end / message_end → the turn count rendered as a string, so matcher = "^1$" scopes a hook to the first turn only
  • input → the empty string. Only * or an omitted matcher will ever match; any real regex silently never fires.

The compaction events are the one place matcher is still deliberately overloaded: it tests the reason, while session_id in the payload carries the real session id. Before v0.12 those two were the same field, and session_id on a compaction event was the string "threshold". That is fixed — see the payload table below.


Wire protocol

stdin

One line of JSON, newline-terminated:

{"event":"tool_execution_start","tool_name":"bash","tool_call_id":"call_abc","arguments":{"command":"rm -rf /"},"cwd":"/home/u/proj","session_id":"019..."}
Field Notes
event snake_case name of the event — tool_execution_start, before_agent_start, session_compact, …
tool_name omitted when the event has no tool
tool_call_id the provider's id for the call in flight. Populated since v0.11.0 — it was hardcoded None before, which left tool_execution_end hooks unable to correlate a result with the start that preceded it
arguments event-specific payload, see below
cwd the session's working directory
session_id omitted on some paths; present for tool, turn and session events

arguments by event:

Event Payload
tool_execution_start the tool's argument object, verbatim
tool_execution_end {"tool_input":{…},"tool_response":{"content":"…","is_error":false,"duration_ms":123}}
before_agent_start {"turn_count":N,"prompt":"…"}
input {"prompt":"…"}
turn_start {"turn_count":N,"iteration":I}
turn_end {"turn_count":N,"iteration":I,"had_tool_calls":bool}
message_end {"turn_count":N,"response_length":N}
all four session / compaction events {"reason":"…"}

The session payload gained a reason (v0.12)

All four session-lifecycle events used to send an empty {}. They now send {"reason": …}:

Event reason values
session_start startup, new, resume, fork, import
session_shutdown quit, new, resume, fork, import
session_before_compact / session_compact threshold, manual

Two things follow. session_shutdown fires on /new, /resume, /fork and /import as well as on quit, so a cleanup script that should only run on the real exit must now check arguments.reason == "quit" rather than assuming.

And a compaction hook that read session_id to learn threshold vs manual must be rewritten to read arguments.reason. Three separate things went into that one field before v0.12; session_id now always carries the real session id, on every event, and no payload field lies.

Environment

The hook inherits the full parent environment plus:

Variable Set for
NANOPI_EVENT always — the CamelCase name (ToolExecutionStart, SessionStart, …)
NANOPI_CWD always
NANOPI_SESSION_ID when a session id exists
NANOPI_TOOL_NAME non-session events (empty string when there is no tool)
NANOPI_TOOL_CALL_ID only when a tool_call_id exists, i.e. the two tool events

NANOPI_EVENT moved with the rename too: it reads ToolExecutionStart, not PreToolUse. A script that branches on $NANOPI_EVENT needs updating alongside the config key.

NANOPI_EVENT exists so a single script can be wired to several events and branch on $NANOPI_EVENT without parsing stdin at all.

Talking back

There are exactly two channels, and one shortcut:

1. Exit code 2 = block. stderr becomes the reason (blocked by hook if stderr is empty).

echo "refusing: touches production" >&2
exit 2

2. A JSON object on stdout. The last non-empty line is parsed, so log freely above it. Two shapes:

{"decision":"block","reason":"why"}
{"decision":"allow","updated_input":{"command":"ls -la"}}

hookSpecificOutput is accepted as an alias for updated_input, for scripts written against Claude Code.

3. Anything else = allow. Exit 0 with no output, exit 1, unparseable stdout — all allow.

Precedence rules worth knowing:

  • decision:"block" wins over a simultaneous updated_input. A blocking hook refuses outright; it never silently rewrites and proceeds.
  • updated_input alone transforms, with or without "decision":"allow".
  • A non-object updated_input (a string, null) is ignored — tool arguments are always objects.

What a Transform actually replaces

  • tool_execution_start — the entire argument object. The tool then runs on the replacement, and the TUI card is re-rendered to show what actually ran rather than what the model asked for.
  • before_agent_start / input — only the prompt key is honored. turn_count is nanopi's, not the hook's, to rewrite.
  • tool_execution_end — nanopi reads content (string) and is_error (bool) from the top level of your updated_input. This trips people up: the payload you received has them nested under tool_response, but the object you return must not nest them.
{"decision":"allow","updated_input":{"content":"[redacted]","is_error":false}}

Because tool_execution_end fires before the ToolResult event is emitted, a rewrite there is what the user sees on screen and what the model sees in context.


Ordering, chaining, failure

Hooks for one event run sequentially, in config order:

  • an entry whose type is not "command" is skipped
  • an entry whose matcher does not match is skipped
  • a Transform feeds the next hook — rewrites chain
  • the first Block stops the chain; remaining hooks do not run

Timeout is a Block. timeout defaults to 5000 ms; on expiry the child is killed and the outcome is a block whose reason names the duration. So a hung tool_execution_start hook refuses the tool call, while a hung turn_start hook merely costs 5 seconds of wall clock — and now says so on stderr, via the advisory-block report.

A crash fails open. If the command cannot be spawned at all, nanopi allows the action and prints to stderr:

nanopi: hook errored; failing open (allow) [tool=bash matcher=* error=...]

An invalid regex fails closed. matcher_matches returns false for an unparseable pattern, so the hook never fires. Matchers are also validated at config-load time, which reports the first bad one by index.


Configuration

Hooks are read from four files, and all four accumulate — a project hook never replaces a global one, both run:

  1. ~/.nanopi/config.toml — [hooks.*] (primary since v0.6)
  2. ./.nanopi/config.toml — [hooks.*] (primary)
  3. ~/.nanopi/settings.toml — legacy
  4. ./.nanopi/settings.toml — legacy

The order above is the execution order. config.toml is the file to use; settings.toml is kept only for backward compatibility.

Each hook is a TOML array-of-tables entry keyed by the snake_case event name:

[[hooks.tool_execution_start]]
matcher = "^bash$"          # regex on the tool name; "*" or omitted = all
type    = "command"         # only "command" exists; default
command = "~/.nanopi/hooks/guard.sh"   # ~, $HOME, ${HOME} expand
timeout = 5000              # ms; default 5000; expiry = block

A relative command is passed to the shell unchanged and resolves against the process cwd.

CamelCase ([[hooks.ToolExecutionStart]]) does not deserialize. The keys are snake_case, on both files.

Renamed in v0.12, and the old keys do not load

[hooks] carries #[serde(deny_unknown_fields)], so an unrecognised key is a parse error for the whole file — nanopi does not start with a config it cannot fully honour. Any of the four retired names lands there, and the error names its replacement:

unknown hook event "pre_tool_use" — renamed to "tool_execution_start" in v0.12 (see docs/v0.12-events.md §2.1)

That message is a rewrite of serde's raw "unknown field" text, applied to both config.toml and the legacy settings.toml, and it keeps the path of the offending file. An ordinary typo (turn_startt) is not rewritten — you get serde's original message, which already lists every valid key, and that is more useful than a guess.

Why an error and not a warning. A silently-ignored [[hooks.pre_tool_use]] block is the worst possible outcome: the user believes they have a guard on bash and they have nothing, with nothing anywhere saying so. A config that fails to load is loud, and the fix is a one-line edit. There is deliberately no alias — an alias would keep two vocabularies alive forever, which is the thing v0.12 set out to end.

Turning everything off

nanopi --no-hooks

The emergency switch. It gates every call site including the session lifecycle ones, so nothing fires at all — and since v0.12 it also stops WASM plugin event delivery, because the plugin delivery sites sit behind the same hooks_active() check. One switch, every lifecycle observer.


Example: refuse dangerous shell commands

~/.nanopi/hooks/guard.sh:

#!/usr/bin/env bash
# tool_execution_start guard: refuse `rm -rf` and force-push.
set -euo pipefail

payload=$(cat)
cmd=$(printf '%s' "$payload" | jq -r '.arguments.command // ""')

case "$cmd" in
  *"rm -rf"*|*"push --force"*|*"push -f"*)
    echo "refused by guard.sh: $cmd" >&2
    exit 2
    ;;
esac

exit 0
chmod +x ~/.nanopi/hooks/guard.sh

~/.nanopi/config.toml:

[[hooks.tool_execution_start]]
matcher = "^bash$"
command = "~/.nanopi/hooks/guard.sh"
timeout = 3000

The model sees blocked by hook: refused by guard.sh: rm -rf build/ as the tool result, so it can adapt rather than retry blindly.

Example: redact secrets from tool output

~/.nanopi/hooks/redact.sh:

#!/usr/bin/env bash
# tool_execution_end: strip anything that looks like a token before the
# model (or the screen) ever sees it.
set -euo pipefail

payload=$(cat)
content=$(printf '%s' "$payload" | jq -r '.arguments.tool_response.content // ""')
is_err=$(printf '%s' "$payload"  | jq -r '.arguments.tool_response.is_error // false')

clean=$(printf '%s' "$content" | sed -E \
  -e 's/gh[pousr]_[A-Za-z0-9]{20,}/[REDACTED_GITHUB_TOKEN]/g' \
  -e 's/sk-[A-Za-z0-9]{20,}/[REDACTED_API_KEY]/g' \
  -e 's/(AWS_SECRET_ACCESS_KEY=)[^ ]+/\1[REDACTED]/g')

# content and is_error go at the TOP LEVEL of updated_input.
# -c matters: only the last non-empty stdout line is parsed, and
# pretty-printed JSON ends on a bare "}".
jq -cn --arg c "$clean" --argjson e "$is_err" \
  '{decision:"allow", updated_input:{content:$c, is_error:$e}}'
[[hooks.tool_execution_end]]
matcher = "*"
command = "~/.nanopi/hooks/redact.sh"
timeout = 4000

Both scripts need jq. Both are pure stdin→stdout, so you can test them without launching nanopi:

echo '{"event":"tool_execution_start","tool_name":"bash","arguments":{"command":"rm -rf /tmp/x"}}' \
  | ~/.nanopi/hooks/guard.sh; echo "exit=$?"

Related

Clone this wiki locally