Hooks
Hooks let you run a script at specific points in the agent loop — for example, auto-formatting after every file edit, or running a linter before a commit. A hook is a command the platform executes, with the current phase and tool info passed in as environment variables.
Hook phases
Nexrall Code fires hooks at these points:
| Phase | When it runs |
|---|---|
PreToolUse | Before a tool is executed — can inspect (or veto) the call. |
PostToolUse | After a tool completes — can react to the result (format, lint, notify). |
PostToolBatch | Once after every tool call in a batch resolved, before the next model call — sees the whole batch (tool_calls), where PostToolUse fires per tool (and concurrently on a parallel batch). additionalContext joins the tool results; exit 2 / {"decision":"block"} stops the turn before the next request. |
SessionStart | Once per session, before the first prompt. Stdout (or additionalContext) is added to that prompt as context. |
UserPromptSubmit | Before every prompt is processed. Exit code 2 (stderr = reason) or {"decision":"block"} rejects it — the model never sees it. Stdout is added to the prompt as context. The prompt arrives on stdin as {"hook_event_name","prompt","session_id","cwd"}. |
PreCompact | Right before the conversation is compacted — automatically (trigger: "auto") or by /compact in the CLI ("manual"). Observer only. Matcher auto or manual. |
Notification | The agent is waiting on you (a permission prompt) — wire it to a desktop notification or a chat message. Never blocks. |
PostToolUseFailure | A tool call failed. Payload has tool_name, tool_input, error. Observer only. |
SubagentStart | A sub-agent is about to run. Stdout (or additionalContext) is added to its brief. |
PostCompact | After compaction finished (auto or manual). Observer only. |
SessionEnd | The main agent finished; reason is the stop reason (e.g. clean). |
PermissionRequest | Just before the CLI asks you to approve a tool. Exit 0 with {"decision":"allow"} (or hookSpecificOutput.decision.behavior) approves; deny rejects; deny wins over allow, and a crashing hook never approves. It cannot override deny rules, destructive-command guards or plan mode. |
Stop / SubagentStop | The main agent (or a sub-agent) finished its turn. Observer only; the payload carries reason (the stop reason, e.g. clean). |
StopFailure | The turn ended because the API call failed, instead of Stop. Matcher is the error type: rate_limit, overloaded, authentication_failed, billing_error, invalid_request, model_not_found, max_output_tokens, server_error, unknown. Observer only. |
FileChanged | A watched file in the working directory changed on disk. Matcher is the filename ("a.txt|b.txt"). Fired by the CLI host; see below. Observer only. |
InstructionsLoaded | The CLI read instruction files (the root brief, memory, a subdirectory nexrall.md picked up on touch, or a .nexrall/rules/ file). Payload: file_path, memory_type (User|Project|Local|Managed), load_reason (session_start | nested_traversal | path_glob_match). Observer only. |
TaskCreated | A shared task was created (create_shared_task). Payload: task_id, task_subject, task_description, plus the tool input. Exit 2 / {"decision":"block"} deletes the task and returns the reason to the model as the tool's error — a veto never leaves a task behind. |
TaskCompleted | A shared task is about to be marked completed (update_shared_task_status). Same payload as TaskCreated. Exit 2 / {"decision":"block"} refuses the completion — the task stays open and the reason goes back to the model. |
Elicitation | An MCP server asked you for input mid-tool-call (elicitation/create). Matcher = server name. A hook whose stdout carries hookSpecificOutput.action ("accept" | "decline" | "cancel", plus content for a form) answers outright — the dialog is skipped, so a CI can fill a form nobody is there to type; the first hook that answers wins. Exit 2 declines. Payload: message, mode (form|url), requested_schema (form mode), url + elicitation_id (url mode). |
ElicitationResult | After an elicitation was answered — by a hook or by the user — before the answer goes back to the server. Same shape overrides it (overrides apply in order, the last wins; accept without content keeps the user's values); exit 2 blocks the response, which means decline with the content dropped. Matcher = server name. Payload: action, content?, mode, elicitation_id?. |
When a tool call needs approval, the decision is reached in this order: a deny rule (and plan mode) refuses first and no hook runs; then PermissionRequest hooks; then — in a non-interactive run, where nobody can be asked — the --permission-prompt-tool MCP tool, if one was given; otherwise the call is denied. So a hook can approve or reject what the prompt tool would have seen, but it can never widen a lock that already refused.
These hooks run under nex --print too, so a policy in settings.json holds in CI — but note the one case they are never asked about there: a destructive command. Approving one is the human's job in the interactive path (typing the confirm), and a file on disk is not allowed to stand in for it; --permission-prompt-tool, which the operator writes on the command line per run, is the deliberate exception.
Configuring hooks
Hooks are configured in settings.json under a hooks key, keyed by phase:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write", "command": "./.nexrall/hooks/lint-before-write.sh" }
],
"PostToolUse": [
{ "matcher": "*", "command": "npx prettier --write ." }
]
}
}Each entry names a matcher (which tools it applies to) and a handler. Both spellings of an entry are accepted, and a list may mix them:
- the short form the examples above use —
{ "matcher": "Edit|Write", "command": "…" }(notypemeans command), and - Claude Code's nested form —
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "…" }] }, which is also what the VS Code extension writes and what agent frontmatter uses.
A handler's type is command (a shell command, the default), http (a POST), prompt / agent (a model decision) or mcp_tool (a tool on a connected MCP server).
A command hook gets the event JSON on stdin; exit 2 blocks the call and what it wrote to stderr is what the model reads. Plugins can also ship hooks via a hooks.json (or hooks/hooks.json), and hook arrays are concatenated across enabled plugins — see Plugins.
Three options work on any handler:
once— run the hook at most once per session. Only a successful run counts (exit0), so a failure, exit2or a timeout leaves it armed for the next event./clearstarts a new session and re-arms everyoncehook.statusMessage— the text the CLI's status line shows while that hook runs (e.g."building index…"). It replaces the normal spinner label and disappears when the hook finishes.if— a conditional hook: one permission-rule string, and the hook runs only when the current tool call matches it — the same matching as deny/ask rules, so env prefixes, wrappers and compound lines are seen through."if": "Bash(git commit *)"makes a pre-commit check spawn for commits instead of every bash call. In the nested form it works on the entry (gates the whole group) or on a handler (gates that one);PreToolUseandPostToolUseare the phases that consult it.
{
"hooks": {
"SessionStart": [
{ "command": "./scripts/index-repo.sh", "once": true, "statusMessage": "indexing the repo…" }
],
"PreToolUse": [
{ "matcher": "Bash", "if": "Bash(git commit *)", "command": "./.nexrall/hooks/pre-commit-check.sh" }
]
}
}Background hooks
A command hook can run in the background instead of holding up the turn, with async or asyncRewake:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "./scripts/full-test-suite.sh", "async": true, "statusMessage": "running tests…" }
]
}
]
}
}async: true— the hook is started and the turn continues immediately. It can never block or veto a tool, and no timeout is enforced. If it writes a JSON object to stdout ({"additionalContext": "…"},{"systemMessage": "…"}), that text is delivered to the model ahead of its next request — it appears in the conversation as a<hook-context>block, never as something the user typed.asyncRewake: true— same, except a hook that exits2wakes the model immediately: its stderr (or stdout when stderr is empty) starts a new turn. This is the "tell me when the thing is done" switch — a long build or test run can report back on its own. Unlike plainasync, itstimeoutis enforced, so a stuck hook cannot strand the wake.
An exit code 2 from a plain async hook does nothing (a background hook cannot block); it is only meaningful with asyncRewake. Both options are command-only — http, prompt, agent and mcp_tool handlers always run in-turn.
HTTP hooks
A handler can instead be type: "http", which POSTs the same JSON payload to a URL — handy for a webhook, a policy service or a CI gate:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"headers": { "Authorization": "Bearer $MY_TOKEN" },
"allowedEnvVars": ["MY_TOKEN"],
"timeout_ms": 30000
}
]
}
]
}
}Header values only expand environment variables you list in allowedEnvVars; anything else is replaced with an empty string, so a hook config cannot leak the rest of the environment into a request. timeout (seconds, Claude Code style) and timeout_ms both work, capped at 10 minutes.
The response is read with the same protocol as a command hook's stdout:
| Response | Result |
|---|---|
2xx, empty body | success — the tool proceeds |
2xx, JSON object | parsed as { "decision": "block" | "allow", "reason": …, "additionalContext": … } (for PermissionRequest, hookSpecificOutput.decision.behavior is accepted too) |
2xx, any other body | non-blocking error — the tool proceeds |
non-2xx, connection failure, timeout | non-blocking error — the tool proceeds |
There is no HTTP equivalent of exit code 2: to block, answer 2xx with the JSON directive. Agent definitions can declare type: "http" hooks too.
Prompt and agent hooks
A handler can also be decided by a model instead of a script:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "prompt", "prompt": "Reject anything that writes outside the repo. $ARGUMENTS is the event JSON." }
]
}
]
}
}type: "prompt"sends the event JSON plus your instruction to the session's model (or the handler's ownmodel). The answer must be one JSON object:{"ok":true}allows,{"ok":false,"reason":"…"}blocks, and{"ok":true,"additionalContext":"…"}adds context. An answer that cannot be parsed is a non-blocking error — never a veto.type: "agent"is the same, but the model may first investigate with the four read-only tools (read_file,search_files,list_directory,glob) before deciding. It cannot write, run commands or reach the network.- Both bill your account like any other model call, and both honour the hook's
timeout(seconds) /timeout_ms. $ARGUMENTSis replaced with the event JSON; write\$ARGUMENTSfor a literal.
MCP-tool hooks
A handler can call a tool on a connected MCP server — useful when the hook's logic already lives behind MCP (a linter server, a policy server):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "mcp_tool",
"server": "policy",
"tool": "check_path",
"input": { "file_path": "${tool_input.file_path}" }
}
]
}
]
}
}server/toolname the server and the tool on it. The shorter spelling —{ "matcher": "Edit|Write", "server": "policy", "tool": "check_path" }— is understood as anmcp_toolhandler too, since there is no command to run.inputvalues may embed${…}references into the event JSON:${tool_input.file_path}(Claude Code's spelling),${input.file_path}and a bare${file_path}all resolve the same way. A value that is exactly one${…}keeps its original type —${tool_input.count}stays a number.- The tool's output is read exactly like a command hook's stdout: text that starts with
{is the directive JSON (decision,reason,additionalContext), any other text is passed to the model as context. There is no exit-code channel, so to block a tool the hook returns{"decision":"block","reason":"…"}. - With no MCP server connected (or the server has no such tool) the hook reports a non-blocking error and the tool call proceeds — a hook must never be able to break the session by accident.
FileChanged hooks
FileChanged runs when a watched file changes: matcher names the file(s), and the payload is {"hook_event_name":"FileChanged","file_path":…,"event":"add"|"change"|"unlink"}. The CLI watches the files named by the matchers while the session runs. A hook may answer with:
{"watchPaths": ["other.txt", …]}— add files to watch (this replaces what a previous answer added; matcher files stay watched).{"systemMessage": "…"}— a one-line notice shown to the user.
Both events are observers: output and exit codes are ignored.
The OS watch takes a moment to arm, so a save that lands in the instant the session starts could go unnoticed; the watcher therefore re-checks the watched files' stamps once shortly after it starts, and reports such a change too. A single save is still reported exactly once.
Observing hook runs in a headless stream
Hooks are normally invisible to a script — they run, they influence the turn, and nothing is printed. In a headless run with --output-format stream-json, add --include-hook-events to watch every hook run as it happens:
nex --print "run the tests" --output-format stream-json --include-hook-eventsEach hook run then reports itself with system lines on stdout, in Claude Code's shape:
{"type":"system","subtype":"hook_started","hook_id":"hook_mr1_2","hook_name":"npm test","hook_event":"PreToolUse","uuid":"…","session_id":"…"}
{"type":"system","subtype":"hook_progress","hook_id":"hook_mr1_2","hook_name":"npm test","hook_event":"PreToolUse","stdout":"…","stderr":"","output":"…"}
{"type":"system","subtype":"hook_response","hook_id":"hook_mr1_2","hook_name":"npm test","hook_event":"PreToolUse","stdout":"…","stderr":"","output":"…","exit_code":0,"outcome":"success"}hook_idpairs a run's records — thehook_started, anyhook_progressand the finalhook_responsefor one handler all carry the same id.hook_nameis the handler as you wrote it: the command, the URL, orserver:toolfor an MCP-tool hook.hook_eventis what fired it —PreToolUse,SessionStart,FileChanged, …hook_progresscarries the output produced since the previous progress line, and only appears once the hook has been running for a second — a quick hook is just astarted/responsepair. Progress flushes at most every 250 ms, and the final unflushed chunk is dropped rather than emitted late:hook_responsealways carries the complete output.hook_responseends the run:exit_code(present only when the hook actually exited) andoutcome—success,error(non-zero exit or a crash), orcancelled(killed by its timeout).
Without the flag, only SessionStart (and Setup) hook runs are streamed — the same always-on pair Claude Code emits for session startup. Everything else stays out unless asked for. The flag needs --output-format stream-json; passing it with any other format is refused at startup rather than ignored.
Environment available to the hook
When a hook command runs, the platform sets these variables so the script knows what triggered it:
| Variable | Value |
|---|---|
NEXRALL_HOOK_PHASE | The event name — PreToolUse, PostToolUse, FileChanged, … |
NEXRALL_TOOL_NAME | The tool being invoked |
NEXRALL_TOOL_INPUT | The tool's input, JSON-encoded |
These are the "Injected into hooks" variables described in Environment variables.
Security
Hooks run arbitrary commands as part of your agent sessions, so they're a trust boundary:
- Plugins that define hooks require explicit confirmation before install — this can't be bypassed with
--yes(see Plugins). - A hook you write yourself should be treated like any other script in your repo — it runs with your user's privileges.
Next steps
- Environment variables — the injected
NEXRALL_HOOK_PHASE/NEXRALL_TOOL_*variables. - Plugins — how plugins bundle and merge hooks.
- Agent SDK —
pluginHooksin the npm package.