Hooks
A hook is something you ask Mercury to run at a moment it has: a shell
command, a question a model answers, or a crewmate that checks something.
Hooks live in settings files (user, project, local, the --config file and
managed policy) under events.hooks; a skill, an agent or an extension may
bring its own. A hook sees the moment's facts on its input and answers with
one small JSON object; the answer may block the moment, change what the model
sees, add words for the model, or say nothing. /hooks lists every hook the
session carries.
Declaring hooks
{
"events": {
"hooks": {
"tool.before": [
{ "match": "Bash", "run": "./scripts/check-command.sh", "timeout": 30 },
{ "match": "Edit|Write", "question": "Does this edit keep the file's own style? $EVENT" }
],
"turn.answer": [
{
"name": "tests ran",
"crewmate": "Check that the tests the answer claims to have run were really run."
}
],
"session.state": [
{ "match": "needs-you|stalled", "run": "~/bin/ping-me.sh", "background": true }
]
}
}
}
Each event names a list of entries, and one entry is one hook. An entry names exactly one of:
run: a shell command. The moment's facts arrive on stdin as one JSON line; the answer is the exit code and stdout.question: a question a model answers.$EVENTin the text is replaced by the facts as JSON; without it, the facts are appended. The model answers the shape below as structured output.crewmate: a brief a crewmate with the session's read tools checks, no asks, at most 50 turns inside its timeout;$EVENTas above. It answers the shape below through its verdict tool.
and may carry:
name: the words every line about the hook uses, and the status row while it runs; without it, the first line of the command or question. While atool.beforeortool.afterhook holds a call, the working strip and the call's own row readwaiting on hook <name> · 12 s of 30: the seconds so far against the hook's timeout, until the hook answers or is ended.match: names (Bash,Read|Edit), a regular expression, or permission-style tool rules on tool events (tool.before,tool.after,permission.ask,permission.decided), matched against the event's match field in the table below. Absent, or*, matches everything. On a tool event,Bash(git *)andPowerShell(…)match the command (and a command rule also matches each subcommand of a compound command, soBash(git push *)matchescd x && git push);Read(…),Edit(…),Write(…),NotebookEdit(…),NotebookRead(…),Glob(…)andGrep(…)match the path with permission-rule patterns (Edit(*.ts));Crewmate(mercury-scout)matches the crewmate type;WebFetch(domain:example.com)matches the URL's host anddomain:*.example.comits subdomains; andmcp__filesormcp__files__*matches every tool on thefilesMCP server. Clauses join with|(Edit(*.rs)|Write(*.rs)). An entry whose match is not a valid regular expression is a named fault and does not load; so is a match on an event that has no match field, a rule with arguments on an event that is not a tool event, and one reading arguments the permission rules cannot match. Arunhook that cares about something a rule cannot say reads its stdin and exits 0 at once when the call is not its business:#!/bin/sh input=$(cat) case "$(printf '%s' "$input" | jq -r .input.command)" in "git commit"*) ;; *) exit 0 ;; esacshell:bash(the default) orpowershell;runonly.model: the model aquestionorcrewmatehook runs on; without it, the session provider's small model answers a question and its light model runs a crewmate.timeout: seconds; 600 for arun, 30 for aquestion, 60 for acrewmate; at most 2147483.background:runonly: the hook does not hold the moment; its answer arrives at the start of the next turn.wake:runonly, impliesbackground: a block from the hook wakes the model with the words.once: the hook runs once in a session, then is skipped for the rest of it. Mercury never edits a settings file.watch:file.changedonly: the files to watch, relative to the project.
An entry with a fault (an unknown field, wrong type, field from another kind, or match on an event without one) is named in the session's settings notes and dropped whole; the rest of the file applies. An unknown event name is a fault like any other.
Layers
user → project → local → --config → managed. Entries from every layer
run; an entry identical in kind, text and match across layers runs once.
events.disabled in any layer turns off every hook that is not managed; in
the managed layer it turns off every hook. events.managedOnly in the
managed layer runs only the managed layer's hooks. extensions.exclusive
naming hooks runs only managed and extension hooks. An untrusted workspace
runs no checkout hook: a headless run loads the user, --config and
managed layers only, and an interactive session runs nothing until the
workspace is trusted (see Workspace trust and permissions).
Skills, agents and extensions
A skill's hooks: frontmatter is the same map, registered while the skill is
in the session and removed when it leaves; its hooks are named
skill:<name>. An agent's hooks: frontmatter is the same map, registered
for that crewmate's own turns only (its turn.*, tool.* and permission.*
hooks fire for its calls and nothing else), named agent:<type>. An
extension's contributes.hooks is the same map with run entries only,
named extension:<name> (see Extensions). A faulty entry in
any of them is named and dropped; the rest loads.
The worktree command
workspace.worktree.prepare is a plain setting (outside the hook system): a shell command
Mercury runs inside every new worktree after git makes it and before any
session uses it: a crewmate’s isolation worktree, --worktree, EnterWorktree.
It has no input and no answer; a failure is named on the row that made the
worktree, and the worktree is used anyway.
Events
Every input carries event, session_id, transcript_path, cwd,
permission_mode (when a turn is running), and crewmate_id and
crewmate_type when the moment happened inside a crewmate, so a monitor
tells a crewmate's tool call from the lead's by the presence of crewmate_id,
and a stalled call by wall_ms on tool.after. turn_id is one
id wherever it appears: the turn row's id on the main thread, the crewmate's
own run key inside a crewmate. The event adds its own fields:
| Event | Fires | Fields | Match field | An answer may |
|---|---|---|---|---|
turn.start |
the user's prompt is in and the model is about to be asked | turn_id, prompt |
none | block, stop, context |
turn.answer |
the model has answered and would end its turn (inside a crewmate too) | turn_id, answer, again (true when this fire follows the hook's own send-back) |
none | block (the model is sent back with the words and keeps working), stop, context |
turn.end |
a turn is over, however it ended; a cut turn carries its cut and that fire runs under a 1.5 s budget | turn_id, status (completed, blocked, refused, interrupted, turn_limit, budget_limit, time_limit, schema_unmet, loop_stopped, failed), stop, error ({message, class}), cut ({reason, detail, tools}: the user's own stop, idle-timeout, parent-stop or cut, its words, the calls it ended), steps, wall_ms, cost_usd, usage, answer |
status |
nothing |
tool.before |
a tool call is about to run, after its input was validated and before the permission decision | tool, input, call_id |
tool |
block, permission (allow or ask), input, context, stop |
tool.after |
a tool call ended: it returned, returned an error, or threw; a call a cut ended fires under a 1.5 s budget | tool, input, output, call_id, ok (false when it returned an error or threw), error, cut (true when a cut caused the failure), wall_ms (the call's wall time in milliseconds) |
tool |
block (the model reads the words beside the result), output, context, stop |
permission.ask |
a permission ask is about to reach the user, or a crewmate that cannot ask would be denied | tool, input, call_id, suggestions |
tool |
permission: "allow" (with input and rules), block (a deny), stop; silence lets the ask go on |
permission.decided |
a tool call's permission was decided without the hook's own answer | tool, input, call_id, decision (allowed, denied), by (rule, mode, hook, the user's own decision, safety, other), reason |
tool |
nothing |
crewmate.start |
a crewmate is launched; the crewmate fields name it | name, prompt, model, directory |
crewmate_type |
context (words the crewmate reads before its first turn) |
crewmate.end |
a crewmate ended | name, status (finished, failed, stopped), reason, crewmate_transcript_path, usage |
status |
nothing |
compaction.before |
the conversation is about to be summarised; it cannot be blocked | trigger (manual, auto, overflow), instructions, tokens |
trigger |
instructions (guidance appended to the summariser's) |
compaction.after |
a compaction rung landed: the summary, the memory notes, or the digest that cleared old tool results | method (summary, notes, digest), trigger, tokens_before, tokens_after, summary |
method |
context (words re-added after the cut) |
session.start |
a session's runner came up | reason (new, resumed), model |
reason |
context, prompt (the session's first prompt, new sessions only), watch; and the environment file |
session.end |
the runner is leaving; a crash fires nothing; run hooks only, 1.5 s for the batch |
reason (quit, logout, closed) |
reason |
nothing |
session.state |
a hosted session moved to a board state worth acting on; fired by the daemon with the hooks of the session's workspace (its project and local layers when the workspace is trusted); run hooks only |
state (needs-you, stalled, ready-to-review, paused, completed, failed, cancelled), from, detail, title, workspace, model |
state |
nothing |
file.changed |
a watched file changed; the entry's watch names the files |
path (relative to the project), change (changed, added, removed) |
path |
watch (replaces the list); and the environment file |
A side question Mercury asks for itself, such as the note for the next session, fires no turn hook.
A by of mode includes a posture that answered an ask on its own; safety
is one of Mercury's own guards: the wards, the commit gate, the path-safety
check.
What a hook answers
One shape, every kind, read against the event. A field the event does not
read is a named failure ("input is not an answer tool.after reads"):
block: the moment is blocked with these words: a refused tool call, a refused prompt, the model sent back onturn.answer, a deny onpermission.ask. The model reads the words.stop: the whole turn ends now; the user reads the words; the model reads nothing more.context: words the model reads at this moment.notice: one line the user reads, saved in the session.permission:allow(no ask, though a deny rule still denies) orask(the user is asked); a deny isblock.input: the input the tool runs with instead;output: what the model sees as the result instead;rules: permission updates applied with an allow;instructions: guidance for the summariser;prompt: the session's first prompt;watch: the files to watch.
A run hook answers with its exit code and its stdout:
- exit 0: stdout is the answer: empty, one JSON object as above, or plain
text, which the user sees in transcript mode; on
session.start,turn.start,crewmate.startandcompaction.afterplain text iscontextthe model reads; - exit 2: the moment is blocked and stderr is the words; stdout is not read;
- any other exit, a timeout, a kill or a command that could not run: the
hook failed, the moment proceeds unless
failClosedblocks it, and the user reads one line naming the hook, the event and what happened. JSON that is not the answer shape is a failure. A hook need not read its input.
Every kind is ended at its timeout (the command killed, the model call
abandoned, the crewmate stopped), and the line reads
hook <name> (<event>) timed out after <n>s and was killed, followed by whether the guarded event proceeded or was blocked.
session.end, a cut turn.end and a cut tool.after run under a 1.5 s
budget, or the hook's own timeout when shorter.
A background hook keeps its clock. Of its answer only context and
notice are read, at the start of the next turn; block is read only on a
wake hook, where it wakes the model with the words. Anything else in a
background answer is a named failure. At session end the running background
hooks are ended.
Several hooks on one moment run at once. Any block blocks, and the words of
every block are joined in order; every context is added in order; for
permission, deny beats ask beats allow; for a field only one hook may set
(input, output, prompt, instructions) the last entry in layer order
wins and the user reads a notice naming both.
What each outcome shows: a hook that ran with nothing to say shows nothing
(/hooks lists what is wired); context reaches the model and the
transcript; a block is a red line, hook <name> blocked <event>: <words>,
and the model reads the words; a stop ends the turn with its words; a
notice is one line; a failure is one line. Every line is saved with the
session, survives a resume, and is drawn by a daemon-hosted chat from
the session file.
A run hook inherits your shell environment by default, the variables you
exported, without provider keys; credentials in Mercury's own store are
not added. Mercury removes provider keys, its own session stamps, session tokens, OpenTelemetry
variables and browser-secret variables, then adds
MERCURY_PROJECT_DIR, MERCURY_SESSION_ID and MERCURY_HOOK_EVENT; on
session.start and file.changed also MERCURY_ENV_FILE, a file where
lines of NAME=value reach every later shell command of the session; an
extension's hooks also get MERCURY_EXTENSION_ROOT, MERCURY_EXTENSION_DATA
and MERCURY_EXTENSION_OPTION_<KEY>. stdout and stderr are each bounded at
10 MB.
Policy
Managed policy settings can tighten the hook surface: events.disabled
turns off every hook, managed ones included, and events.managedOnly
restricts execution to the hooks the managed settings define. The trust gate
that keeps a project's hooks from running before the workspace is trusted is
on Workspace trust and permissions.