What this is. The full reference for Biorouter's hook system: the lifecycle events you can hook, how matchers select them, the two hook types (
commandandprompt), the stdin/exit-code/JSON contract, and the blocking and rewriting semantics. Status: Current. Audience: end users configuring hooks, and developers working on the agent loop.
Hooks let you run your own shell commands — or an LLM judge — at specific points in Biorouter's agent lifecycle: before a tool runs, after a prompt is submitted, around context compaction, when a session starts or ends, and more. Use them to enforce guardrails, inject context, log activity, or trigger notifications.
Hooks work everywhere the agent runs: the desktop app, the CLI, scheduled runs, and subagents.
Hooks live under a hooks: section in your global config
(~/.config/biorouter/config.yaml):
hooks:
PreToolUse:
- matcher: "developer__shell"
hooks:
- type: command
command: "$HOME/hooks/shell-guard.sh"
timeout: 30
PreCompact:
- matcher: "auto"
hooks:
- type: command
command: "cp ~/.config/biorouter/sessions/* ~/backups/ 2>/dev/null || true"The shell-guard.sh referenced above is written out in full under
Worked examples.
Projects can ship their own hooks in .biorouter/hooks.yaml at the session working
directory, using the same schema under a top-level hooks: key. Project hooks are
disabled by default — a repository should not be able to run commands on your
machine just because you opened it. Opt in globally with:
hooks:
allow_project_hooks: true(or BIOROUTER_ALLOW_PROJECT_HOOKS=1). Project hooks run in addition to global
hooks, never instead of them.
Note. An administrator can deploy a managed policy that adds mandatory hooks which cannot be disabled, and that forces or forbids project hooks org-wide. Managed config wins over everything on this page — the precedence is
Default < User (global config) < Project (opt-in) < Managed (admin). If a hook you did not configure is running, or yourallow_project_hookssetting appears to be ignored, check Managed / enterprise policy.
| Event | Fires | Can block? | Matcher matches on |
|---|---|---|---|
PreToolUse |
before a tool call executes | yes — deny / ask (and may rewrite the input) |
tool name |
PostToolUse |
after a tool call succeeds | yes — block (capped at 3) |
tool name |
PostToolUseFailure |
after a tool call fails | yes — block (capped at 3) |
tool name |
PermissionRequest |
when a tool would show an approval prompt | yes — allow / deny |
tool name |
UserPromptSubmit |
when a user prompt is submitted | yes | — |
Stop |
when the agent is about to finish its turn | yes (capped at 5) | — |
SessionStart |
first prompt of a session in this process | no (context only) | startup | resume |
SessionEnd |
CLI exit, headless completion, scheduled-run completion | no | exit reason |
Notification |
when a permission prompt is shown | no | permission_prompt |
PreCompact / PostCompact |
around context compaction | no | manual | auto |
SubagentStart / SubagentStop |
around a subagent task | no | — |
Matchers: omit (or use "" / "*") to match everything; otherwise exact match,
a|b alternation, or a full regex (anchored). Tool names follow the
extension__tool convention, e.g. developer__shell, developer__.*.
A matcher only sees the tool name, so a shell guard would run on every shell
call. Add an optional input_matcher to narrow a group to specific tool
arguments — "only guard rm -rf", "only writes under /etc":
hooks:
PreToolUse:
# A single regex, searched against the whole tool_input JSON.
- matcher: "developer__shell"
input_matcher: "rm\\s+-rf"
hooks:
- type: command
command: "$HOME/hooks/confirm-destructive.sh"
# Or a map of field -> regex; every entry must match.
- matcher: "developer__text_editor"
input_matcher:
command: "^(write|str_replace)$"
path: "^/etc/"
hooks:
- type: command
command: "echo 'no edits under /etc' >&2; exit 2"- Field paths are dotted and may index arrays:
path,params.path,argv.0. Non-string values (numbers, booleans, nested objects) are matched against their JSON text. - Unlike the tool-name matcher,
input_matcherpatterns are searched, not anchored —rm\s+-rfhits anywhere in the value. Anchor explicitly with^…$when you mean the whole value. - A missing field never matches, and a group with an
input_matchernever runs on an event that carries no tool input (Stop,UserPromptSubmit, …). Aninput_matchercan only narrow a group, never widen it. - An invalid regex is logged once and treated as non-matching.
command — a shell command. It receives the event as JSON on stdin and runs in
the session working directory with BIOROUTER_HOOK_EVENT, BIOROUTER_SESSION_ID,
and BIOROUTER_PROJECT_DIR set. Default timeout 60s (timeout overrides, in
seconds).
prompt — an LLM judge. The rule you write is evaluated against the event
payload by your configured provider (its fast model when available, or an explicit
provider: + model: pair). The judge answers {"ok": true|false, "reason": "..."};
ok: false blocks. Default timeout 30s.
hooks:
PreToolUse:
- matcher: "developer__shell"
hooks:
- type: prompt
prompt: "Block any command that deletes files outside the project directory."Input on stdin, with snake_case keys:
{
"session_id": "…",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"tool_name": "developer__shell",
"tool_input": {"command": "rm -rf build"}
}Exit codes:
- 0 — success. stdout may contain a JSON decision (below). For
UserPromptSubmitandSessionStart, plain (non-JSON) stdout is injected as context for the model. - 2 — block. stderr is used as the reason: for
PreToolUseit is fed back to the model, forUserPromptSubmitit is shown to the user, forStopit becomes feedback the agent must address before finishing. - anything else — non-blocking error; the event proceeds (failure-open).
Optional JSON on stdout, with camelCase keys:
{
"decision": "block",
"reason": "tests have not been run",
"systemMessage": "shown to the user as a yellow notice",
"hookSpecificOutput": {
"permissionDecision": "allow | deny | ask",
"permissionDecisionReason": "…",
"additionalContext": "injected for the model",
"updatedInput": {"command": "rm -rf ./build"}
}
}When several hooks match one event they run in parallel and the most restrictive
decision wins (deny > ask > allow).
A PreToolUse hook does not have to choose between allowing and denying: it can
return hookSpecificOutput.updatedInput — the tool's complete replacement
argument object — to sandbox a path, redact a payload, or normalize a command. The
rewritten call is what executes and what the transcript records, and the model is
told (as injected context) that its arguments were changed, so it never silently
works from a call it did not make.
#!/bin/bash
# PreToolUse, matcher developer__shell — pin any rm to the build dir
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // ""')
case "$command" in
"rm -rf /"*)
jq -nc '{hookSpecificOutput: {hookEventName: "PreToolUse",
updatedInput: {command: "rm -rf ./build"}}}'
;;
esacRules worth knowing:
updatedInputis honored only onPreToolUse(elsewhere the tool has already run, or the call is already recorded); on any other event it is ignored and logged.- It must be a JSON object (the full argument map, not a patch) and is capped at 256 KB. A malformed rewrite is a non-blocking error — failure-open, like every other hook mistake.
- The rewritten input is re-validated: every other inspector (the catastrophic-command denylist, the permission gate) re-runs on the new arguments, so a rewrite cannot smuggle a call past the safety gates. The hook inspector itself is not re-run — a rewrite cannot trigger another rewrite.
- A hook that both denies and rewrites is treated as a deny (the call never runs,
so there is nothing to rewrite). Hooks run concurrently, so rewrites do not chain:
if two hooks return
updatedInputfor the same call, the last one (in config order) wins and the conflict is logged.
PostToolUse / PostToolUseFailure hooks can block (exit 2, or
{"decision":"block"}) — e.g. reject a write that fails lint. The tool has already
run, so its side effects stand and its output is preserved; the hook's reason is
appended to the tool result, the result is marked as an error, and the agent keeps
working on the correction. Consecutive blocks are capped at 3 per session (the
model typically retries the tool, so an unconditional blocker would otherwise wedge
the turn); past the cap the result is delivered anyway with a notice.
The wire format is deliberately Claude Code-compatible, so most hook scripts written for it run unchanged. Where other agents spell a field differently, the alternate spelling is accepted as an alias.
| Surface | Biorouter spelling | Also accepted |
|---|---|---|
| stdin event payload | snake_case (hook_event_name, tool_name, tool_input) |
Claude Code uses the same keys |
| stdout decision payload | camelCase (decision, systemMessage, hookSpecificOutput) |
Claude Code uses the same keys |
| rewritten tool arguments | hookSpecificOutput.updatedInput |
Codex updated_input, Gemini CLI tool_input |
- Failure-open everywhere. A crashing, timing-out, or misconfigured hook never blocks the agent; only explicit decisions do.
- Rewrites are re-validated. See Rewriting a tool call — a rewritten call still passes through the denylist and the permission gate.
- PreToolUse
denyturns the tool call into an error result containing your reason, so the model can adapt.askroutes the call through the normal approval dialog with your reason attached.updatedInputrewrites the call instead of refusing it (see above). - PermissionRequest
allowauto-approves a call that would otherwise prompt the user — useful for trusted commands in trusted projects. It covers approvals the permission mode raised, which is nearly all of them. It does not cover the small fixed set of approvals BioRouter raises whatever your mode — a prompt-injection finding, an Auto-mode write to a credential store, a global (machine-wide) memory read or write, a managed-policyask. Those exist precisely because no automated grant should answer them, and a hook is an automated grant: anallowon one is logged and dropped, and the card is shown as if no hook had run. See what still asks, whatever your mode.denyis unrestricted — a hook can always refuse anything. additionalContextandsystemMessagework on every tool-path event,PreToolUseandPermissionRequestincluded. The context reaches the model wrapped in the<hook-context untrusted="true">frame used for all injected hook output, and thesystemMessagesurfaces as a yellow inline notice.
- Stop blocks are capped at 5 consecutive blocks per session; the payload field
stop_hook_activeistrueon re-checks so well-behaved hooks can exit early. - PostToolUse blocks are capped at 3 consecutive blocks per session (see Blocking a tool result).
Observe-only events run detached, but are not discarded. Notification,
SubagentStart / SubagentStop and PreCompact / PostCompact cannot block, so
they are dispatched in the background rather than on the agent's critical path.
Their systemMessage still reaches you — it is collected at the next turn boundary,
where any outstanding hook is also joined. A hook slower than the boundary's short
wait is never waited on; it simply surfaces one boundary later, and is joined at
session end.
Hook activity (blocks, judge verdicts, systemMessages) appears as yellow inline
notices in both the CLI and the desktop app chat.
- GUI sessions do not fire
SessionEnd. The desktop app has no reliable session-close signal, soSessionEndfires only on CLI exit, headless completion, and scheduled-run completion. PreCompactcan fire without a matchingPostCompact. They are not a bracket.PreCompactis fired speculatively — before a summarization whose outcome is not yet known, which is what makes it "pre" and what gives a hook its chance to capture the transcript before it is replaced.PostCompactfires only when a compaction actually landed, so it is skipped when the summarizer errors, and when the write-back is declined because another writer changed the history while the summary was being computed (see Conversation writeback freshness). Firing it anyway would be worse than the asymmetry: it would tell every consumer the transcript had been replaced when it had not, so a hook that re-indexes the history, invalidates a cache, or reports "compacted to N tokens" would act on a history that never changed. ReadPreCompactas "a compaction is about to be attempted", and do not pair acquire/release work across the two events without a timeout of your own.
Block destructive shell commands — this is the shell-guard.sh referenced from
Configuration, and it guards on the command text inside the
script. To do the same filtering in config instead, use an
input_matcher.
#!/bin/bash
# ~/hooks/shell-guard.sh — PreToolUse, matcher developer__shell
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // ""')
if echo "$command" | grep -qE 'rm -rf|mkfs|dd if='; then
echo "Destructive command blocked by shell-guard" >&2
exit 2
fiRequire a clean test run before the agent finishes:
hooks:
Stop:
- hooks:
- type: command
command: "cargo test --quiet 2>/dev/null || { echo 'tests are failing; fix them before finishing' >&2; exit 2; }"
timeout: 300Inject lab context at session start:
hooks:
SessionStart:
- hooks:
- type: command
command: "cat ~/.config/biorouter/lab-context.md 2>/dev/null"For a ready-made, maintained version of the "don't finish until it builds and is committed" pattern, see the verify-and-checkpoint Stop hook.
- Verify-and-checkpoint Stop hook — a shipped Stop hook that applies this contract to build/test verification and git commits.
- Managed / enterprise policy — how an admin tier adds mandatory hooks that override everything on this page.
- Permission modes — the approval gate that
PermissionRequesthooks andupdatedInputrewrites are re-validated against. - Config file reference — the
structure and location of
config.yaml, the file thehooks:block lives in. - Subagents — the tasks that
SubagentStart/SubagentStopwrap.