Skip to content

Hooks

A hook is an artifact that observes a lifecycle point, receives structured input from the runtime, and optionally returns output that the runtime commits. Hooks are not tools: the model cannot call them, they are omitted from the tool inventory, and hook errors are non-fatal — failures are logged to workdir/logs/hook-<name>.log and the session continues.

Binding — when the hook fires:

Value When
on-stage During stage_session, before the agent loop begins. Once per launch.
on-session-start After staging, before the first task's work begins. Once per capsule launch.
on-task-start Before that task's first inference turn. Once per task.
on-inference After each inference response is received.
on-tool-call Before each tool invocation is dispatched, and again after it returns.
on-shell Before each shell command is dispatched, and again after it returns.
on-compaction When session tokens reach the compaction threshold.
on-task-end Immediately after that task's agent loop returns. Once per task.
on-session-end After the task loop exits (idle timeout, shutdown, or explicit exit). Once per capsule launch.
(omitted) All session events (on-session-start through on-session-end). Does not include on-stage.

Execution mode — whether the agent loop waits for the hook. blocking (the default) stops the loop until the hook returns; async enqueues the event and lets the loop continue. If the loop needs the hook's answer, use blocking; otherwise async keeps the work off the critical path — which matters for a hook doing network I/O on every event, since a blocking one pays that round trip inline every time. An async hook is one reused instance per session, so in-memory state persists across calls; its calls are serialized in dispatch order and finish before the session ends. See Async hook execution for overflow rules.

Commit policy — what the runtime does with the hook's output.

commit_policy What the runtime does Typical use
none Discards the output Observability hooks
replace-context Replaces the conversation history Compaction
write-manifests Writes tool manifest records to workdir/tools/<binary>/murmur.yaml, overwriting any existing file Shell tool enrichment during staging
reopen-task Re-runs the task's agent loop with the hook's feedback instead of finalizing it — see Task reopening Review and retry hooks
seed-context Places the hook's messages at the head of the task's first message list, under the context.seed_budget ceiling — see Context seeding Memory
deny Refuses the shell command or tool call the hook was asked about, before it runs — see Policy hooks Guardrails

binding is the single source of truth for what a hook commits: each binding honors exactly one arm, so it admits that one policy plus none, and a commit_policy the binding cannot honor fails at capsule-staging time — before the hook component is compiled — with an error naming the binding, the declared policy, and the policy the binding honors. A hook with no binding: receives every event, so any policy is valid for it — with the single exception of deny, which requires an explicit binding: of on-shell or on-tool-call. See Hook contract fields.

The two fields meet in one rule: a binding that commits an arm must be blocking, because every committable arm is a decision the agent loop is blocked on — the context it continues from, the manifests it stages with, the feedback that reopens a task. async is available exactly where a hook commits nothing, so it requires commit_policy: none, and on-stage may never be async.

Of the shipped hooks, murmur-hook-debug is the only async one, and it is stateless — it appends one JSON line per event and nothing waits on it. murmur-hook-grafana and murmur-hook-eval declare no execution_mode and so run blocking even though both commit nothing: each buffers session state in memory across every call and exports it once at on-session-end.

name: murmur-hook-example
version: 1.0.0
runtime: hook
binding: on-compaction           # when it fires
execution_mode: blocking         # blocking or async
commit_policy: replace-context   # none, replace-context, write-manifests, reopen-task, seed-context, deny
description: "Hook description."

A hook's capability grant is read from the capsule's manifest, not from the hook's own — see Hook capabilities.

Policy hooks

A hook declaring commit_policy: deny on binding: on-shell or binding: on-tool-call is a policy hook. Every other hook is an observer.

Observer hook Policy hook
When it is called After the call, with what the call produced Before the call, with what the call is about to do
outcome on the event Present Absent
What its answer changes Nothing about the call — it has already run Whether the call happens at all
A failure means The session continues, the failure is logged The call is refused

A policy hook is called at a decision point placed after the manifest's capability check and immediately before the call is dispatched. It is handed the resolved identity of what is about to run: the resolved absolute path of the executable, the exact untruncated argument list, the -c script body, and for a tool call the exact input JSON. Returning deny(reason) means the call is not made. The agent is handed a result naming the hook and its reason, and stating that retrying the same call unchanged will be refused again. A call_denied event records the refusal; no tool_call or shell event is written, because nothing ran.

A policy hook only narrows. There is no arm that permits a call. The manifest decided what the capsule may do, and a policy hook is a second, stricter gate standing in front of that decision — so a hook can never make a capsule able to do something its manifest did not allow. A shell binary missing from capabilities.shell.allow is still refused with the same message whatever the hook returns.

A policy hook that fails refuses. This is the inversion of the non-fatal default that governs every other hook, and it is the rule a policy hook exists to provide: a gate that opens when it breaks is not a gate. The call proceeds only on a clean none. A returned deny, a crash, a call that outruns its deadline, a memory-limit kill, a return the runtime cannot read, a deny with an empty reason, and any other hook-output arm all refuse the call, with a reason naming the hook and the defect. Nothing in the manifest, the environment or the CLI changes this.

The inversion applies at the decision point alone. The observation dispatch of the same two events keeps the non-fatal default: a deny returned there is a dispatch fault, logged and traced and honored by nothing, because the call has already happened.

A policy decides on argv, script and input, never on commandcommand is truncated for display. For make <target>, just <recipe> and npm run <script> the runtime reads the named recipe out of the capsule's workdir and carries its body on shell-event.recipe, so a policy gating just build decides on the text the justfile gives build. An absent recipe means the runtime resolved no body, and says nothing about the call.