Hooks
Hooks are commands Mework runs at fixed points of a turn — when you send a message, around each tool call, when the model is about to stop — to check, block or rewrite what happens, or to add context for the model. They use Claude Code's hooks format, so a block copied from Claude Code works without edits.
Where hooks.json lives
| Level | File | Used by |
|---|---|---|
| Global | ~/.mework/hooks.json (Windows: %USERPROFILE%\.mework\hooks.json) |
Every conversation |
| Workspace | <workspace>/.mework/hooks.json |
Conversations that include that workspace |
A conversation can select from the global hooks and those of all its workspaces. Global hooks run on this computer. For a workspace on WSL or an SSH machine, Mework reads the workspace's hooks.json on that machine, and its hooks run there. When the conversation works in an isolated worktree of a workspace, that workspace's hooks.json is still read from its own folder. The temporary project's scratch folder has no .mework; a conversation there gets the global hooks and those of the workspaces it attached. An old .naiword/hooks.json is read only when there is no .mework/hooks.json beside it.
Format
<workspace>/.mework/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^(bash|zsh|sh|powershell)$",
"hooks": [
{
"type": "command",
"name": "No force push",
"command": "python3 .mework/hooks/no_force_push.py",
"commandWindows": "python .mework/hooks/no_force_push.py",
"timeout": 10
}
]
}
]
}
}<workspace>/.mework/hooks/no_force_push.py:
import json, sys
call = json.load(sys.stdin)
if "push --force" in call["tool_input"].get("command", ""):
print("Force pushes are not allowed in this project.", file=sys.stderr)
sys.exit(2)Under hooks, each event holds a list of groups; each group has an optional matcher and a list of handlers. A file that is missing, is not valid JSON or has no top-level hooks object gives no hooks. A UTF-8 byte-order mark at the start is fine.
Events
Event names are case-sensitive; any other name is skipped.
| Event | Fires | Matcher is tested against |
|---|---|---|
SessionStart |
The conversation's first turn since Mework started, so again after a restart | startup |
UserPromptSubmit |
Every turn, before the first model request | (ignored) |
PreToolUse |
Before each tool call, ahead of the approval check | The tool name |
PermissionRequest |
When a call would show an approval card, or a PreToolUse hook asked for one |
The tool name |
PostToolUse |
After a tool ran; not for calls refused before they ran | The tool name |
Stop |
When the model ends its turn without calling a tool | (ignored) |
InstructionsLoaded |
When MEWORK.md, a rule file or an import loads; its exit code and output change nothing |
The load reason |
Subagents and workflow steps run PreToolUse, PermissionRequest, PostToolUse and InstructionsLoaded hooks. SessionStart, UserPromptSubmit and Stop run only in the conversation's own turns.
Matchers
- A matcher is a regular expression and is not anchored:
editalso matchesedit_global_memory. Write^edit$for one tool. - No matcher, an empty one, or
"*"matches everything. An invalid regular expression drops its whole group. - Claude Code's tool names (
Bash,Read,Write|Edit, …) match the Mework tools that do the same job, and Mework's own names (bash,write,edit, …, listed on Built-in tools) work too. - To match every tool of one MCP server, use
^mcp__<server>_.
Handler keys
| Key | What it does | Default |
|---|---|---|
type |
Must be "command". Handlers of any other type, or none, are skipped. |
— |
command |
Required. The command to run. On this computer, macOS: bash -lc <command>; Windows: pwsh, or Windows PowerShell if pwsh is missing, with UTF-8 output. On WSL or an SSH machine, the shell follows that machine's system. |
— |
commandWindows |
Replaces command when the hook runs on a Windows machine. |
— |
name |
The name in the list and on the timeline card. | statusMessage, then <Event> #<n> |
statusMessage |
Text on the card while the hook runs. | "Running" |
timeout |
Seconds, 1–600. Out of range removes the handler. | 30 |
async |
true is allowed only on InstructionsLoaded; on any other event it removes the handler, as asyncRewake: true does. |
— |
Choose hooks for a conversation
- Open More options → Conversation settings → Hooks (see Conversation settings).
- Tick the handlers this conversation should run. Each row is one handler, described by its event and matcher, such as "Before a tool runs · matcher ^bash$".
The list refreshes by itself shortly after you save hooks.json; Rescan refreshes it at once, and runs always use the file on disk. The list has a Global section, then one section per workspace (see Conversation settings). The folder button on a section's heading, Open the global config folder or Open the config folder of {path}, shows that level's hooks.json, creating the .mework folder if needed; for a WSL or SSH workspace, it opens the folder on that machine in the Files pane. The built-in mework preset selects no hooks.
The selected hooks are listed in the system prompt, so while the model's prompt cache is warm, ticking or unticking one is drawn orange and asks once before it applies.
Note A selection always refers to the handler you ticked. If you insert, delete or reorder handlers in
hooks.json, a selection whose handler moved or was removed shows as Dangling, and every run fails until you untick it; then tick the handler's new row.
Confirmation before each turn
At the Manual and Accept edits security levels, each turn of a conversation with selected hooks starts with a dialog listing every hook's event, name, matcher and command, and the folder it runs in (with its machine for a WSL or SSH workspace). The hooks of agent roles that pick their own are listed too, marked with the role's name; a subagent runs only hooks the dialog listed. Allow the turn, or cancel to end it. If the list runs past 12,000 characters, the turn is refused; select fewer hooks. At Full access there is no dialog.
How a hook runs
- Where: a workspace's hooks run on that workspace's machine, in the workspace folder or the conversation's worktree of it. Global hooks run on this computer, in workspace 1's folder; when workspace 1 is on WSL or an SSH machine, or is the temporary project's folder, they run in a scratch folder in Mework's data folder instead.
cwdin the hook's input names the folder the hook runs in. - Environment: Mework's full environment for a hook on this computer, or that machine's own environment for a hook on WSL or an SSH machine, with the Environment variables of the hook's workspace (see Workspaces) added, plus
MEWORK_HOOK_EVENTset to the event name andCLAUDE_PROJECT_DIRset to the folder the hook runs in. - Timing: all matching handlers for an event run in parallel, each within its own
timeout. All hooks in one turn share a 5-minute budget; once it is spent, further hooks fail.
Which hooks see a call
Global hooks see everything. A workspace's hooks run for:
- the tool calls that work in that workspace: a tool whose
workspaceargument names it, such asread,write,edit,grep,find,ls,lsp, the shells andpreview_start, with workspace 1 for a call that names none; a project-memory tool naming it; that workspace's own MCP servers; and an instance of a global"workspace": trueserver running in it (see MCP servers); - the conversation's own events:
SessionStart,UserPromptSubmit,Stop, and tool calls that work in no one workspace, such asweb_search,web_fetch,agent_spawn, a preview server named only by its id, and global MCP servers without"workspace": true.
A workspace's hooks never run for another workspace's tool calls. InstructionsLoaded reaches the global hooks and workspace 1's, because the instruction files are workspace 1's.
Input on stdin
Every hook receives one UTF-8 JSON object on stdin with session_id (the conversation), transcript_path (always null), cwd, hook_event_name, model, permission_mode and turn_id. permission_mode is default at Manual, acceptEdits at Accept edits, and bypassPermissions at Full access. Each event adds:
| Event | Extra fields |
|---|---|
SessionStart |
source: always "startup" |
UserPromptSubmit |
prompt: the text of your latest message |
PreToolUse |
tool_name, tool_use_id, tool_input |
PermissionRequest |
tool_name, tool_input, permission_suggestions (always empty) |
PostToolUse |
tool_name, tool_use_id, tool_input (after any rewrite), tool_response |
Stop |
stop_hook_active, last_assistant_message |
InstructionsLoaded |
file_path, memory_type, load_reason, and sometimes globs, trigger_file_path, parent_file_path, but no transcript_path |
tool_input and tool_response use the field names Claude Code scripts read, such as file_path, old_string, new_string and command.
How a hook answers
Exit codes
- 2 — block, with stderr as the reason.
PreToolUseandPermissionRequestrefuse the call.UserPromptSubmitandSessionStartend the turn before the model is asked.PostToolUserejects the result.Stopkeeps the model working. - Any other non-zero code counts as a failed hook. It decides nothing, and the turn goes on.
- 0 — stdout is read. Empty: nothing happens. A JSON object: see below. Plain text: added as context for
SessionStartandUserPromptSubmit, an error forStop, ignored for the rest.
JSON output
| Field | Effect |
|---|---|
continue: false |
Ends the turn, with reason or else stopReason as the reason. |
decision: "block" with reason |
Blocks, as exit code 2 does. On Stop it keeps the model working: reason is sent as a new user message, at most 3 times in a row. |
systemMessage |
Shown on the hook's timeline card, not to the model. |
hookSpecificOutput.hookEventName |
Must equal the current event, or all of hookSpecificOutput is ignored. |
hookSpecificOutput.additionalContext |
Text for the model, delivered after the current tool results as a Delivered context from a hook card, or in the next turn if this one ends first. |
hookSpecificOutput.permissionDecision (PreToolUse) |
allow skips the approval card for this call. ask shows a card, naming the hook, even at Full access or after Always allow. deny refuses the call, with permissionDecisionReason as the reason. |
hookSpecificOutput.updatedInput (PreToolUse) |
With allow or ask, replaces the call's arguments before the approval check. |
hookSpecificOutput.decision (PermissionRequest) |
{"behavior": "allow", "updatedInput": {…}} approves this one call only. {"behavior": "deny", "message": "…", "interrupt": true} refuses it; interrupt also ends the turn. |
Top-level decision: "approve" or "block" (PreToolUse) |
The older form, used only when there is no hookSpecificOutput. |
When several hooks answer the same call, any block or deny wins, and any ask shows a card. An allow counts only if all hooks that allow or ask return the same updatedInput.
A PostToolUse block does not undo the call: the model is told the tool had already finished and its effects stand.
What hooks cannot skip
No hook answer removes these confirmations: MCP tools that must ask every time (see MCP servers), dangerous recursive deletes, writes to global memory, and actions on a browser page you are signed in to.
See what ran
Every hook run except InstructionsLoaded leaves a card in the timeline; expand it for stdout, stderr, systemMessage and the decision. Added to model context marks a hook whose context went to the model. The cards stay on your computer. More options → History also lists each hook run among the requests and tool calls.