Hooks
Hooks run your own commands at three moments: before a tool runs, after a tool runs, and when the model stops. Use them to log, to format, to block, or to continue a turn with your own check.
A hook never widens permissions. A hook that says allow or ask is
refused with a warning and treated as no decision. The call still follows
the permission rules in permissions.md.
Events#
Three events exist. Their names are case-sensitive.
| Event | When it runs | The matcher |
|---|---|---|
PreToolUse |
Before each tool call | Filters by tool name |
PostToolUse |
After each tool call | Filters by tool name |
Stop |
After a completed turn | Ignored; every entry runs |
Any other event name is skipped with one warning. Only a completed turn runs
Stop: a cancelled, failed or otherwise unfinished turn runs nothing.
Where hooks live#
Two layers hold hooks. The user layer is the [hooks] section of the config
file (configuration.md holds the path). The project layer is
<workspace>/.radian/hooks.json. The project file loads only when the
directory is trusted, and it accepts two shapes: the bare events object, or
the wrapped settings shape with a top-level hooks object. When both are
present the wrapped object wins.
At runtime the project entries run first, then the user entries, so user hooks see the final arguments. An identical command string runs once per event.
A missing file is empty, never an error. An unreadable project file warns
once and loads nothing. A hook whose type is not "command", an entry
with handler keys outside the command subset (async, if, http,
prompt, agent), a missing or empty command, a bad timeout, a
non-string matcher, or a missing or empty hooks list is
skipped with one warning each. Nothing outside the subset ever refuses
startup.
The user config shape#
[[hooks.PreToolUse]]
matcher = "shell|write"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo tool call starting >> /tmp/radian-hooks.log"
timeout = 10
[[hooks.PostToolUse]]
matcher = "patch"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "make fmt"
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "notify-send radian stopped"
timeout = 30
Each event holds a list of entries. Each entry holds a matcher and a list
of hooks. Each hook holds type = "command", a command, and an optional
timeout in seconds. The default timeout is 60 seconds; zero means the
default. A second [[...hooks]] table under one entry runs a second
command.
The project file spells the same shape in JSON:
{
"PreToolUse": [
{"matcher": "shell", "hooks": [{"type": "command", "command": "make lint", "timeout": 10}]}
],
"Stop": [
{"hooks": [{"type": "command", "command": "notify-send radian stopped"}]}
]
}
Set [hooks] intent_stop = false to turn off the built-in stop validator
that also runs on the Stop path. It defaults to on. It runs even with no
hooks configured. It stays quiet while a goal or /loop owns the turn end
or while any background job runs. After two consecutive continuations it
stops opening turns and waits visibly instead; your next message clears it.
The matcher#
The matcher filters by Radian tool name: shell, write, patch,
read, grep, glob, job, memory_search, ask_user, skill,
subagent, and the MCP tools as server__tool. The pattern is
case-sensitive. *, a blank string, or a missing matcher matches every
tool. Any other value is a regular expression anchored to the whole tool
name, so shell|write matches the shell and write tools, and sh does
not match shell. A pattern that does not compile matches nothing. Stop
ignores the matcher.
Type /hooks to open the inspector's hooks page. It shows each configured
entry and whether its matcher compiles.
What a hook receives#
The hook command runs as /bin/sh -c with the workspace as its working
directory, inside the session sandbox when one is enforced. Outside the
sandbox it inherits radian's environment; inside it sees only the sandbox's
allowed variables. Its stdin is one
JSON object. The fields depend on the event.
PreToolUse receives the event name, the tool name, the tool input, the
working directory, and the session id:
{
"hook_event_name": "PreToolUse",
"tool_name": "shell",
"tool_input": {"command": "make check"},
"cwd": "/path/to/workspace",
"session_id": "0193…"
}
PostToolUse receives the same fields plus the tool result:
{
"hook_event_name": "PostToolUse",
"tool_name": "shell",
"tool_input": {"command": "make check"},
"tool_response": {"content": "…", "is_error": false},
"cwd": "/path/to/workspace",
"session_id": "0193…"
}
Stop receives the event name, the working directory, the session id, and
the last assistant message's text:
{
"hook_event_name": "Stop",
"cwd": "/path/to/workspace",
"session_id": "0193…",
"last_assistant_message": "Done. …"
}
What a hook returns#
The exit code decides.
| Exit code | PreToolUse |
PostToolUse |
Stop |
|---|---|---|---|
0 |
Continue. Stdout may carry JSON (below); empty stdout continues silently, and stdout that is not JSON warns and continues. | Continue. | Continue. |
2 |
Deny: stderr (at most 2000 characters) becomes the reason. Empty stderr denies with a default message. | The tool already ran, so there is nothing to deny: stderr is fed back to the model with the result, and the result is marked as an error. | Ask for more: the reasons join into one message that opens another turn. Past the wake budget the turn still opens its message but does not wake: it waits visibly with a wake-budget notice instead. |
| other, timeout, or signal | Hook error: the call is denied and the turn ends. | Hook error: the turn ends. | The turn ends with a warning. |
An exit-0 hook may print one JSON object to stdout:
{"hookSpecificOutput": {"permissionDecision": "deny", "reason": "no network writes"}}
permissionDecision accepts only deny. allow and ask are refused with
a warning. An unknown value is ignored with a warning. Either way the rest
of the object still applies. The deny reason is hookSpecificOutput.reason,
or a top-level reason as fallback; an empty or missing both denies with a
default message.
updatedInput replaces the tool input for a PreToolUse hook. It must be
an object; anything else is a hook error. The replacement is re-validated
against the permission rules, and later hooks see the replaced input:
{"hookSpecificOutput": {"updatedInput": {"command": "echo safe"}}}
Worked examples#
Block a command. Deny every shell call whose input mentions a protected
host by exiting 2 with a reason on stderr:
#!/bin/sh
# hooks/block-hosts.sh
input=$(cat)
case "$input" in
*protected-host*) echo "protected host is off limits" >&2; exit 2;;
esac
exit 0
[[hooks.PreToolUse]]
matcher = "shell"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "sh /tmp/block-hosts.sh"
Format after an edit. Run the formatter after every patch and let a
failure ride back to the model with the result:
[[hooks.PostToolUse]]
matcher = "patch"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "make fmt"
Notify on stop. Run one notifier when the turn completes:
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "notify-send radian stopped"
Debugging#
Type /hooks to see what loaded. The startup line names every skipped entry
and every failed run. A hook that never fires usually has a matcher that
does not compile (the hooks page says so) or a tool name that is not a
Radian tool name (MCP tools carry their server__ prefix). A hook that
denies everything usually exits non-zero by accident: check the timeout
(a slow command past 60 seconds fails closed) and check stderr. A project
file that never loads is in an untrusted directory: trust it first.