Repository navigation
Hooks
English · 中文
Tracks v0.12.0 (
3a762b1). Runtime insrc/agent/hook.rs; the eleven call sites are insrc/agent/loop_.rs; config loading insrc/settings.rsandsrc/config.rs. If this page disagrees with those files, the repo wins. Re-verify with:cargo test hook
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.
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.
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.
| 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).
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.
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
thresholdormanual -
before_agent_start/turn_start/turn_end/message_end→ the turn count rendered as a string, somatcher = "^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.
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":"…"} |
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.
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.
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 22. 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 simultaneousupdated_input. A blocking hook refuses outright; it never silently rewrites and proceeds. -
updated_inputalone transforms, with or without"decision":"allow". - A non-object
updated_input(a string,null) is ignored — tool arguments are always objects.
-
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 thepromptkey is honored.turn_countis nanopi's, not the hook's, to rewrite. -
tool_execution_end— nanopi readscontent(string) andis_error(bool) from the top level of yourupdated_input. This trips people up: the payload you received has them nested undertool_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.
Hooks for one event run sequentially, in config order:
- an entry whose
typeis not"command"is skipped - an entry whose
matcherdoes not match is skipped - a
Transformfeeds 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.
Hooks are read from four files, and all four accumulate — a project hook never replaces a global one, both run:
-
~/.nanopi/config.toml—[hooks.*](primary since v0.6) -
./.nanopi/config.toml—[hooks.*](primary) -
~/.nanopi/settings.toml— legacy -
./.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 = blockA 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.
[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.
nanopi --no-hooksThe 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.
~/.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 0chmod +x ~/.nanopi/hooks/guard.sh~/.nanopi/config.toml:
[[hooks.tool_execution_start]]
matcher = "^bash$"
command = "~/.nanopi/hooks/guard.sh"
timeout = 3000The model sees blocked by hook: refused by guard.sh: rm -rf build/ as the tool result, so it can adapt rather than retry blindly.
~/.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 = 4000Both 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=$?"- Config file layout and merge rules → Configuration
- Adding tools and slash commands instead → Plugin-Development
- What a plugin is allowed to touch → Plugin-Capabilities
- Letting a plugin observe these same eleven events → Plugin-Events
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit