Docs

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: edit also matches edit_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

  1. Open More options → Conversation settings → Hooks (see Conversation settings).
  2. 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. cwd in 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_EVENT set to the event name and CLAUDE_PROJECT_DIR set 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 workspace argument names it, such as read, write, edit, grep, find, ls, lsp, the shells and preview_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": true server running in it (see MCP servers);
  • the conversation's own events: SessionStart, UserPromptSubmit, Stop, and tool calls that work in no one workspace, such as web_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. PreToolUse and PermissionRequest refuse the call. UserPromptSubmit and SessionStart end the turn before the model is asked. PostToolUse rejects the result. Stop keeps 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 SessionStart and UserPromptSubmit, an error for Stop, 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.

Edit this page on GitHub