Skip to main content

Hook system

This is the builder-facing reference for lifecycle hooks and the HookRunner contract. For the operator-facing permission and guardrail behavior surrounding hooks, see Permissions and posture.

Hooks run at fixed phases of the agent loop. They can block an action, rewrite what the model sees, or observe an event. Operators deploy hooks; the model cannot install, modify, or disable them. Every event starts as allowed.

Hook phases

PhaseWhen it firesCan block?Can mutate?Mutation target
SessionStartOnce at the very start of a run, before the prompt is recordedYes — aborts the runNo
UserPromptSubmitAfter command expansion, before the prompt is recordedYes — ends the runYesThe prompt text ({"prompt": "..."})
PreToolUseAfter permission clears, before the tool executesYes — substitutes an error result; the tool does not runYesThe tool's args JSON
PostToolUseAfter the tool executes, before the result is emitted to the client or modelNo — the tool already ran; a block only annotatesYesThe result object ({"content": "...", "is_error": false})
StopOnce at the terminal end of any run path, even if the context is already cancelledNo — terminal notification onlyNo
SubagentStopWhen a subagent's loop stops (mirrors Stop for child agents)No — terminal notification onlyNo
TeammateIdleWhen a team member goes idle between roundsNoNo
TaskCreatedWhen the team supervisor creates a taskNoNo
TaskCompletedWhen a team member completes a taskNoNo

SessionStart and UserPromptSubmit are fail-safe: a hook execution error (not just exit 2) also ends the run. PreToolUse and PostToolUse treat execution errors as annotations — neither aborts the run.

Shell hook contract

Each hook is a shell command run as <shell> -c <command> (default /bin/sh). The JSON HookEvent is written to the process's stdin:

{
"Phase": "PreToolUse",
"Tool": "Shell",
"Input": { "command": "rm -rf build" },
"SessionID": "8867bdea940108c1dd82d13d3fb7fc61"
}

Input is phase-specific. For PreToolUse it is the tool's raw arguments JSON. For PostToolUse it is {"content": "...", "is_error": false}. For UserPromptSubmit it is {"prompt": "..."}.

Exit codes

Exit codeOutcome
0Allow — stdout is read as an optional message or mutation envelope
2Block — the action is vetoed; the reason is read from stdout (preferred) or stderr
anything elseHook error — surfaced as an annotation or run abort depending on the phase

A single invocation is bounded by a 30-second timeout.

Mutation envelope

On exit 0, if stdout is a JSON object, it is parsed as a control envelope:

{
"mutated": <phase-specific payload>,
"message": "optional human-readable note"
}

mutated must have the same shape as Input for that phase. For PreToolUse it replaces the tool's arguments before execution. For PostToolUse it replaces the result the model and client see. For UserPromptSubmit it replaces the recorded prompt text.

A malformed (non-JSON-object) stdout is treated as a plain message and the original payload stands — so hooks that only print a message or produce no output at all are unaffected.

Block example: guard Shell against rm -rf

#!/bin/sh
# pretooluse-guard.sh — wire as a PreToolUse hook for the Shell tool.
event="$(cat)"
if printf '%s' "$event" | grep -q '"rm -rf'; then
echo "blocked: 'rm -rf' is not permitted by policy"
exit 2
fi
exit 0

Exit 2 causes Mecatl to substitute an error ToolResult in place of running the command. The model sees a tool failure, not a silent skip.

Mutation examples

Rewrite the prompt before it is recorded

A UserPromptSubmit hook that strips a leaked API key pattern from user input before it reaches the model or the session store:

#!/bin/sh
event="$(cat)"
prompt="$(printf '%s' "$event" | python3 -c "import sys,json; print(json.load(sys.stdin)['Input']['prompt'])")"
clean="$(printf '%s' "$prompt" | sed 's/sk-[A-Za-z0-9]\{32,\}/[REDACTED]/g')"
python3 -c "import json,sys; print(json.dumps({'mutated': {'prompt': sys.stdin.read()}}))" <<< "$clean"
exit 0

The mutated prompt is what gets recorded into the session and sent to the model.

Redact a secret from a tool result

A PostToolUse hook that scrubs AWS credentials from shell output before the model sees it:

#!/bin/sh
event="$(cat)"
content="$(printf '%s' "$event" | python3 -c "import sys,json; print(json.load(sys.stdin)['Input']['content'])")"
clean="$(printf '%s' "$content" | sed 's/AKIA[A-Z0-9]\{16\}/[REDACTED_KEY]/g')"
python3 -c "
import json, sys
content = sys.stdin.read()
print(json.dumps({'mutated': {'content': content, 'is_error': False}}))
" <<< "$clean"
exit 0

Because the mutation happens before the result is emitted, the client stream and the model's conversation history both show the redacted version — there is no divergence.

Permission policy evaluates original args

For PreToolUse, the permission policy runs on the original, pre-mutation args. A hook that rewrites the args is not re-permission-checked after the rewrite. This is deliberate: a hook is operator-deployed and is treated as more trusted than the model. The practical consequence is that a hook can widen a call past the policy that gated the model's original request — for example, normalizing a path that would otherwise have triggered a confirmation. Don't use this to bypass security controls you intend to enforce; use it to implement your own operator-controlled transformations.

Guardrails: a built-in model-backed hook

Mecatl also includes model-backed PreToolUse and PostToolUse hooks for content that scripts cannot reliably classify, such as prompt injection in a fetched page or possible secret exfiltration in tool arguments. These guardrails remain off until you configure a checker model. See Permissions and guardrails for the default matchers, enforcement modes, and approval flow.

What's next