Skip to main content

Hooks

A [[hook]] is a shell command that runs at a lifecycle point, with the event as one JSON object on stdin. The point is that policy, redaction and logging attach to the agent without anyone editing agent.rs.

[[hook]]
event = "pre_tool" # see "The events" below
tools = ["shell"] # empty means every tool
command = "~/.mecha/hooks/no-force-push.sh"
timeout_secs = 10 # default

Each hook runs via sh -c, as you — a run's hooks in the run's workspace, a task's hooks in ~/.mecha. The order in config is the order they run; for pre_tool and pre_task_close the first denial wins and later hooks do not fire.

The events​

Three belong to a run, and three to the task board:

EventPayload on stdinCan it decide?
pre_toolevent, tool, inputYes — exit 2 denies the call
post_toolevent, tool, input, is_error, content (first 4000 chars)No
session_endevent, session_id, pathNo
pre_task_closeevent, record — the move about to be recordedYes — exit 2 refuses to close or reopen the task
task_closedevent, record — the move, plus the closure's readout, follow_up_staged and project_readoutNo
task_reopenedevent, record — the move, with undoes naming the closure it reopensNo

A hook's tools = [...] list applies to pre_tool and post_tool only. The three task events are not tool calls, so a task hook runs for every close or reopen whatever its tools list says.

A task event fires wherever the task is closed or reopened — the terminal, the TUI, the web board, Slack, or a chat where you approved the agent running mecha tasks set — because every one of them goes through the same recorded event. The record is the line written to ~/.mecha/closures/closures.jsonl:

{"event": "task_closed", "record": {"id": "close-…", "task": "task-1a2b3c4d", "from": "next", "to": "done", "move": "close", "actor": "owner", "surface": "web", "sessions": ["20260924T…"], "at": "2026-09-24T…", "readout": "Pride · +1.0 (1 positive, 0 negative signals)", "follow_up_staged": false}}
{"event": "pre_tool", "tool": "shell", "input": {"command": "git push --force"}}

post_tool's content is bounded deliberately: a hook that wants the whole output can read the session file. Stdin is for deciding, not archiving.

A minimal policy hook:

#!/usr/bin/env bash
# exit 0 allows, exit 2 denies with this output as the reason
jq -e '.input.command | test("push +--force")' >/dev/null || exit 0
echo "force-push is not allowed in this workspace"
exit 2

Where hooks sit in the dispatch path​

interlock → pre_tool hook → approver (the human) → execute

Two properties follow from that order, and both are load-bearing.

A hook can narrow policy and never loosen security. The trifecta interlock runs first, so a hook that exits 0 does not un-block a refused send. Hooks are an additional gate, not a replacement for the structural one.

A pre_tool denial never reaches the human. Mechanical policy is cheaper than an interruption, and — the part that matters — a hook cannot be talked into clicking yes. Escalating a rule that a script can decide would put a dialog in front of a user for something already settled, and dialogs are what an injection is trying to produce.

pre_tool fails closed​

OutcomeVerdict
exit 0allow
exit 2deny, with the hook's stdout (or stderr) as the reason
any other exit codedeny
spawn failuredeny
timeout (10s default)deny
warning

A policy hook that cannot run and quietly allows is worse than no hook. It is the silently-degrading-sandbox mistake with different spelling: the operator believes a rule is being enforced and it is not. So anything that is not an explicit exit 0 denies.

The timeout covers the stdin write as well as the wait. That was a real bug: a hook that never reads stdin blocks the write once the payload outgrows the pipe buffer, so a pre_tool hook fed a large fs_write input hung the run forever with the timeout never starting.

pre_task_close fails closed the same way: a hook that cannot run refuses the close, and nothing is recorded or moved.

post_tool, session_end, task_closed and task_reopened are observers. Their failures are logged and swallowed, because an observer must not be load-bearing. If something has to be able to stop a call, it is a pre_tool hook; if it has to be able to stop a closure, pre_task_close.

A hook denial reads differently from a human denial​

// hook
content: format!("Blocked by a hook: {reason}"),
// approver
content: format!("Denied by the user: {reason}"),

The strings are not cosmetic. The learning miner keys on "Denied by the user:" to find the moments you stepped in. Machine policy is not a user correction, and mining it would teach mecha rules it was already obeying mechanically — filling the system prompt with restatements of a script that already runs on every call. Both strings have tests naming that.

Subagents inherit the parent's hooks​

setup::build_subagent installs the parent's HookSet on every child, for the same reason a subagent inherits the outbox route: otherwise delegating would be the way around a pre_tool policy.

Validation, and when hooks are off​

Hook config is validated on every start, including when --no-hooks skips installing it:

// Validated even when --no-hooks skips installing them.
let hooks = mecha_core::hooks::HookSet::from_config(&cfg.hooks)?;
let hooks = (!opts.no_hooks && !hooks.is_empty()).then(|| Arc::new(hooks));

An unknown event name or an empty command is a startup error. A policy hook that never fires because of a spelling should fail on every start, not only on the runs that needed it.

mecha eval forces hooks off, the same way it forces MCP, learned rules, the outbox and provider fallbacks off: a scorecard shaped by this machine's local scripts grades the machine, not the model.

A worked example: self-driving learning​

The reflection pass can be triggered by the close of every session, detached so the hook's timeout never kills a model call in flight:

[[hook]]
event = "session_end"
command = "nohup mecha reflect >/dev/null 2>&1 &"

It names no provider on purpose. Unpinned, it runs on the default — and on a llama-server router with follow_loaded, on whatever model is loaded. A -p there is a pin, and on a router a pin loads that model, so every session's close would undo a model switch. Without a router, add -p for the model you want the pass on; unpinned, it runs on your default provider, which may be a paid API.

Where to go next​