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 are lifecycle gates wired into the agent loop. They fire at fixed phases — before a tool runs, after it returns, when a session starts, when it ends — and they can block an action outright, rewrite what the model sees, or simply observe and notify. Hooks are operator-deployed: the model has no way to install, modify, or disable them. Every event starts as allowed; hooks only restrict or transform.
Hook phases
| Phase | When it fires | Can block? | Can mutate? | Mutation target |
|---|---|---|---|---|
SessionStart | Once at the very start of a run, before the prompt is recorded | Yes — aborts the run | No | — |
UserPromptSubmit | After command expansion, before the prompt is recorded | Yes — ends the run | Yes | The prompt text ({"prompt": "..."}) |
PreToolUse | After permission clears, before the tool executes | Yes — substitutes an error result; the tool does not run | Yes | The tool's args JSON |
PostToolUse | After the tool executes, before the result is emitted to the client or model | No — the tool already ran; a block only annotates | Yes | The result object ({"content": "...", "is_error": false}) |
Stop | Once at the terminal end of any run path, even if the context is already cancelled | No — terminal notification only | No | — |
SubagentStop | When a subagent's loop stops (mirrors Stop for child agents) | No — terminal notification only | No | — |
TeammateIdle | When a team member goes idle between rounds | No | No | — |
TaskCreated | When the team supervisor creates a task | No | No | — |
TaskCompleted | When a team member completes a task | No | No | — |
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 code | Outcome |
|---|---|
0 | Allow — stdout is read as an optional message or mutation envelope |
2 | Block — the action is vetoed; the reason is read from stdout (preferred) or stderr |
| anything else | Hook 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
Everything above is a shell hook you write yourself. Mecatl also ships a built-in PreToolUse/PostToolUse pair that inspects tool content with a dedicated checker model instead of a script — a separate "guardrails" layer, off until you point it at a checker model, then on by default with a sensible ruleset (blocking WebSearch/WebFetch/all MCP tools/Shell) rather than empty. It exists for the case a shell hook can't handle well: judging whether a fetched web page looks like a prompt-injection attempt, or whether a tool call is about to exfiltrate a secret. See Permissions & guardrails for the full picture, including the approve-once recovery flow when a guardrail blocks something you actually wanted.
What's next
- Permissions & guardrails — the other governance surface; controls what the model can request before hooks fire, and the model-backed guardrail checker.
- Extension points — HookRunner — how to implement a custom hook runner as a port adapter.