Skip to content

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:

PhaseWhen it runs
PreToolUseBefore a tool is executed — can inspect (or veto) the call.
PostToolUseAfter a tool completes — can react to the result (format, lint, notify).
PostToolBatchOnce 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.
SessionStartOnce per session, before the first prompt. Stdout (or additionalContext) is added to that prompt as context.
UserPromptSubmitBefore 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"}.
PreCompactRight before the conversation is compacted — automatically (trigger: "auto") or by /compact in the CLI ("manual"). Observer only. Matcher auto or manual.
NotificationThe agent is waiting on you (a permission prompt) — wire it to a desktop notification or a chat message. Never blocks.
PostToolUseFailureA tool call failed. Payload has tool_name, tool_input, error. Observer only.
SubagentStartA sub-agent is about to run. Stdout (or additionalContext) is added to its brief.
PostCompactAfter compaction finished (auto or manual). Observer only.
SessionEndThe main agent finished; reason is the stop reason (e.g. clean).
PermissionRequestJust 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 / SubagentStopThe main agent (or a sub-agent) finished its turn. Observer only; the payload carries reason (the stop reason, e.g. clean).
StopFailureThe 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.
FileChangedA 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.
InstructionsLoadedThe 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.
TaskCreatedA 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.
TaskCompletedA 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.
ElicitationAn 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).
ElicitationResultAfter 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:

json
{
  "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": "…" } (no type means 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 (exit 0), so a failure, exit 2 or a timeout leaves it armed for the next event. /clear starts a new session and re-arms every once hook.
  • 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); PreToolUse and PostToolUse are the phases that consult it.
json
{
  "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:

json
{
  "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 exits 2 wakes 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 plain async, its timeout is 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:

json
{
  "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:

ResponseResult
2xx, empty bodysuccess — the tool proceeds
2xx, JSON objectparsed as { "decision": "block" | "allow", "reason": …, "additionalContext": … } (for PermissionRequest, hookSpecificOutput.decision.behavior is accepted too)
2xx, any other bodynon-blocking error — the tool proceeds
non-2xx, connection failure, timeoutnon-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:

json
{
  "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 own model). 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.
  • $ARGUMENTS is replaced with the event JSON; write \$ARGUMENTS for 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):

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "mcp_tool",
            "server": "policy",
            "tool": "check_path",
            "input": { "file_path": "${tool_input.file_path}" }
          }
        ]
      }
    ]
  }
}
  • server / tool name the server and the tool on it. The shorter spelling — { "matcher": "Edit|Write", "server": "policy", "tool": "check_path" } — is understood as an mcp_tool handler too, since there is no command to run.
  • input values 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:

sh
nex --print "run the tests" --output-format stream-json --include-hook-events

Each hook run then reports itself with system lines on stdout, in Claude Code's shape:

json
{"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_id pairs a run's records — the hook_started, any hook_progress and the final hook_response for one handler all carry the same id.
  • hook_name is the handler as you wrote it: the command, the URL, or server:tool for an MCP-tool hook. hook_event is what fired it — PreToolUse, SessionStart, FileChanged, …
  • hook_progress carries 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 a started/response pair. Progress flushes at most every 250 ms, and the final unflushed chunk is dropped rather than emitted late: hook_response always carries the complete output.
  • hook_response ends the run: exit_code (present only when the hook actually exited) and outcome — success, error (non-zero exit or a crash), or cancelled (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:

VariableValue
NEXRALL_HOOK_PHASEThe event name — PreToolUse, PostToolUse, FileChanged, …
NEXRALL_TOOL_NAMEThe tool being invoked
NEXRALL_TOOL_INPUTThe 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 ​

Built by Maxrall, Inc.