Lifecycle hooks
A hook is a shell command that WAMP runs at a fixed point in a turn. It can add context for the model, refuse a prompt, refuse a tool call, or ask you to approve one. A hook can only restrict. It cannot allow anything WAMP would otherwise refuse, and it cannot rewrite a tool’s input or output.
The file format and the input fields are the ones Claude Code uses, so a script written for Claude Code can read its input unchanged. Tool names are WAMP’s (see Matchers).
Where hooks live
Section titled “Where hooks live”| File | Runs |
|---|---|
~/.wamp/hooks.json |
In every project |
<project>/.wamp/hooks.json |
Only after you trust the project, together with yours |
A chat with no repository runs in your home directory, so there the project file and your own file are the same file, and it is read once. Hooks are read once per send: an edit applies from the next message, and a running turn keeps the hooks it started with. Revoking trust in a project stops its hook commands at once, including any that are still running. A Cloud session trusts its own workspace, so a repository’s project hooks run there as they do in a trusted project.
A first hook
Section titled “A first hook”This hook refuses any shell command that contains rm -rf:
{ "hooks": { "PreToolUse": [ { "matcher": "shell", "hooks": [{ "type": "command", "command": "~/.wamp/bin/guard-shell.sh", "timeout": 10 }] } ] }}#!/bin/shinput=$(cat)case "$input" in *'rm -rf'*) echo "rm -rf is not allowed from chat" >&2; exit 2 ;;esacexit 0Make the script executable with chmod +x ~/.wamp/bin/guard-shell.sh. Exit
code 2 refuses the call, and the model receives stderr as the reason.
File format
Section titled “File format”hooks maps an event name to a list of groups. Each group has an optional
matcher and a list of handlers.
| Field | Meaning |
|---|---|
matcher |
Tool events only. Selects the tools the group runs for (see below) |
type |
Must be "command". Other handler types are skipped with a warning |
command |
Run by the shell, with the project root as the working directory |
timeout |
Seconds. Defaults to 60 and is capped at 600 |
An unknown event, field or handler type is reported as a warning in the chat. The rest of the file still applies.
Matchers
Section titled “Matchers”An empty matcher or * matches every tool. A list such as shell|write matches
those exact names. Anything else is an unanchored JavaScript regular expression.
Matchers use WAMP tool names, such as shell, write, edit, apply_patch
and mcp__<server>__<tool>. Claude Code names such as Bash are not
translated. A literal name that matches no registered tool produces a warning,
so a copied "Bash" matcher does not fail silently.
Events
Section titled “Events”| Event | Fires | A hook can |
|---|---|---|
SessionStart |
On the first message of a conversation, before UserPromptSubmit |
Add context |
UserPromptSubmit |
On each message sent from a chat or through engine.run.start, before the turn is accepted |
Refuse the prompt, add context |
PreToolUse |
Before the permission decision for each tool call | Deny the call, ask for approval |
PostToolUse |
After the call has run, whether it succeeded or failed | Add feedback to the result |
Stop |
At a native Run’s natural finish, after the Goal gate | Keep the agent working with a reason |
Goal continuations and scheduled turns are not prompts, so they fire neither
SessionStart nor UserPromptSubmit. Tool events still fire during those
turns; native root Runs still fire Stop when they naturally finish. A blocking
Stop reason becomes the next user message. The agent can be kept working twice;
if the hook still blocks on the final check, the Run finishes with that reason
unresolved. An abort, error or user Stop does not fire the hook. With a foreign
agent runtime, such as an ACP agent, the prompt events
fire, but tool events fire only for WAMP tools lent to that agent. The agent’s
own tools run inside its process, where WAMP does not see them. ACP turns do not
fire Stop.
Each handler receives one JSON object on stdin:
| Field | Present |
|---|---|
hook_event_name, session_id (the conversation id), cwd (the project root), run_id, agent, runtime |
Always |
tool_name, tool_input, tool_use_id |
Tool events |
tool_response ({ content, is_error }) |
PostToolUse |
prompt |
UserPromptSubmit |
source ("startup") |
SessionStart |
stop_hook_active (true after a Stop block in this Run) |
Stop |
There is no transcript_path, because WAMP keeps no transcript file. The
environment adds WAMP_PROJECT_DIR and removes WAMP_CORE_URL and
WAMP_CORE_SECRET_FILE, so a hook does not inherit the engine’s control
connection.
Output
Section titled “Output”| Result | Effect |
|---|---|
| Exit 0, no output | Nothing |
| Exit 2 | Stderr is the reason. UserPromptSubmit refuses the prompt, PreToolUse denies the call, PostToolUse adds the reason as feedback, Stop keeps the agent working, and SessionStart ignores it |
| Any other exit code, or a timeout | A warning in the chat. The turn continues |
| Plain text on stdout | Context for SessionStart and UserPromptSubmit. A warning for the other events |
| JSON on stdout | Read as shown below |
JSON fields:
| Field | Effect |
|---|---|
hookSpecificOutput.permissionDecision: "deny" or "ask", with permissionDecisionReason |
PreToolUse refuses the call or asks you about it |
decision: "block" with reason |
UserPromptSubmit refuses the prompt. PostToolUse adds the reason as feedback. Stop keeps the agent working |
hookSpecificOutput.additionalContext |
Context for SessionStart and UserPromptSubmit, or feedback for PostToolUse |
continue: false with stopReason |
Same as exit 2, with stopReason as the reason |
updatedInput or updatedOutput |
Same as exit 2, because WAMP does not apply rewrites |
permissionDecision: "allow", suppressOutput |
Ignored, with a warning |
Several matching handlers run in parallel. If any of them denies, the call is denied, and a deny outranks an ask. Context from all handlers is joined and capped at 10,000 characters. Each handler’s stdout and stderr are each read up to 64 KiB.
Denials and approvals
Section titled “Denials and approvals”A deny refuses the call in every mode, including Full access, even if you allowed that tool earlier in the chat. An ask shows one approval card with the hook’s reason. The card offers only Allow and Deny, never “allow for this chat”. When nobody can answer, as in an unattended run, an ask becomes a refusal.
A refused prompt never enters the chat, and its reason appears as a warning.
Through engine.run.start, the call fails with a failed-precondition error whose
data carries the reason.