Skip to content
Developer Preview

Hooks in plugins

After this page you can write a hook that runs on a session event, decide whether it blocks, adds context or stops the turn, and test it from the command line before you approve it. For the exact event names, stdin keys and output fields, use Hook events and payloads. Every output block on this page was produced with Muse Code 1.3.0.

A hook is a program that Muse Code runs when something happens in a session: a prompt is submitted, a tool is about to run, the model wants to stop. The program reads one JSON object on stdin, does its work, and answers with its exit code and, optionally, a JSON object on stdout.

A plugin hook ships inside a plugin. You declare it in the plugin manifest, install it with muse plugins install, and approve it once. The standalone form, a hook you configure yourself in settings.json, in a project’s .muse/hooks.json or in a managed file, is documented in Hooks. Settings-level hooks compared with plugin hooks lists the differences row by row: shell string versus argv array, matcher groups versus event only, timeout in seconds versus timeoutMs, and per-hook approval instead of file-level trust.

The event names, the stdin payload, the output schema and the size caps are the same for both kinds, and Hook events and payloads is the one reference for them.

A hook is one object in the hooks array of .muse-plugin/plugin.json. This is the hook from the weather example:

"hooks": [
{
"id": "log-tool-use",
"event": "PostToolUse",
"command": ["sh", "hooks/log-tool-use.sh"],
"timeoutMs": 5000,
"statusMessage": "Logging tool use"
}
]

The entry needs an id (the capability id, unique among the plugin’s hooks), an event (one of the names in Hook events and payloads, spelled in PascalCase) and a command (an argv array whose first element is the program; a relative path in it must name a regular file inside the plugin, and two hooks may not name the same file). timeoutMs (default 600000, ten minutes; values below 1000 are raised to 1000), statusMessage (shown in the terminal UI while the hook runs), async (true makes the hook observation-only: it runs concurrently and its output never changes the turn) and the advanced compatibilityName are optional. There is no matcher field. A native hook keys on event; a matcher entry fails validation with unsupported-field. Manifest gives every field its type, default and rules.

As argv, with no shell. Muse Code starts command[0] directly with the remaining elements as arguments. Nothing is split, globbed or substituted, so "$MUSE_PLUGIN_ROOT/x" arrives as those literal characters. Native hook and MCP commands get no placeholder substitution: Muse Code rewrites exactly one argv element, the one that spells a relative path declared in the manifest, to the installed copy of that file. For any other file, read MUSE_PLUGIN_ROOT inside your script and build the path yourself.

In a cleared environment. The child inherits none of your environment. It receives a short allowlist (HOME, PATH, USER, LOGNAME, TMPDIR, TEMP, TMP, SHELL, LANG, LC_ALL and TERM when they are set, plus four more on Windows) and the plugin variables MUSE_PLUGIN_ID, MUSE_PLUGIN_ROOT (the installed, read-only copy of the plugin) and MUSE_PLUGIN_DATA_DIR (a per-plugin directory for files the hook writes), with PLUGIN_ROOT and CLAUDE_PLUGIN_ROOT as aliases of the root and PLUGIN_DATA and CLAUDE_PLUGIN_DATA as aliases of the data directory for scripts written for other hosts. Hook events and payloads lists the environment in full. Provider API keys, proxy settings and toolchain variables never reach a hook. The MUSE_PLUGIN_DATA_DIR directory may not exist yet, so create it before writing. There is no session id in the environment; read session_id from stdin.

The following SessionStart hook prints what it was given. It was declared as "command": ["sh", "hooks/env.sh", "$MUSE_PLUGIN_ROOT/literal"]:

hooks/env.sh
#!/bin/sh
echo "argv0=$0"
echo "arg1=$1"
echo "cwd=$(pwd)"
echo "stdin=$(cat)"
echo "env-names: $(env | cut -d= -f1 | sort | tr '\n' ' ')"
echo "data-dir-exists=$( [ -d "$MUSE_PLUGIN_DATA_DIR" ] && echo yes || echo no )"

Run through muse plugins hook test with a fixture whose cwd is /tmp, its stdout was (the cache path is shortened to <data dir>):

argv0=<data dir>/plugins/cache/local/hookdemo/81821f74…/package/hooks/env.sh
arg1=$MUSE_PLUGIN_ROOT/literal
cwd=/private/tmp
stdin={"hook_event_name":"SessionStart","session_id":"s1","cwd":"/tmp","source":"startup"}
env-names: _ CLAUDE_PLUGIN_DATA CLAUDE_PLUGIN_ROOT HOME LANG LOGNAME MUSE_PLUGIN_DATA_DIR MUSE_PLUGIN_ID MUSE_PLUGIN_ROOT PATH PLUGIN_DATA PLUGIN_ROOT PWD SHELL SHLVL TERM TMPDIR USER
data-dir-exists=no

The relative path hooks/env.sh was rewritten to the installed copy; the $MUSE_PLUGIN_ROOT argument was not. _, PWD and SHLVL are set by sh itself.

With one JSON object on stdin. Every payload carries hook_event_name, session_id, cwd, transcript_path, model, permission_mode and, except on SessionStart and SessionEnd, turn_id. Each event adds its own keys: tool_name, tool_input and tool_use_id on tool events, prompt on UserPromptSubmit, source on SessionStart, and so on. Read the whole of stdin before you exit; a payload larger than 256 KiB is not delivered and the hook is skipped for that event. The working directory of the process is the cwd in the payload.

Under a timeout. When timeoutMs elapses the process is killed and the hook is recorded as timed_out. The session continues.

Muse Code reads the exit code first, then stdout. The exit codes and the JSON answer are the same for every hook source; How a hook answers gives the short form and Hook events and payloads every field. In brief: exit 0 succeeds and may carry a JSON answer on stdout, exit 2 with text on stderr blocks on the events that can block, and any other exit code is a failure.

JSON output is a closed schema in camelCase. Any field that is not in the schema, is misspelled, is written in snake_case, or is not allowed on the current event rejects the whole output; the hook is recorded as failed with a diagnostic and none of its output is applied.

Four small hooks show the shapes and how muse plugins hook test --json reports them. Each result below is trimmed to decision (empty fields removed) and the interesting terminal fields.

A PreToolUse hook that denies:

hooks/deny.sh
#!/bin/sh
cat >/dev/null
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"rm -rf is not allowed by the hookdemo policy"},"systemMessage":"hookdemo blocked a shell command"}'
{
"decision": {
"should_block": true,
"block_reason": "rm -rf is not allowed by the hookdemo policy",
"permission_decision": "deny"
},
"status": "blocked",
"exit_code": 0,
"system_message": "hookdemo blocked a shell command",
"effects": ["blocked", "permission_denied"]
}

A Stop hook that keeps the model working, using exit 2:

hooks/exit2.sh
#!/bin/sh
cat >/dev/null
echo "tests have not been run yet; run them before stopping" >&2
exit 2
{
"decision": {
"should_block": true,
"block_reason": "tests have not been run yet; run them before stopping"
},
"status": "blocked",
"exit_code": 2,
"stderr": "tests have not been run yet; run them before stopping\n",
"effects": ["blocked"]
}

A PostToolUse hook that stops the turn:

hooks/stop.sh
#!/bin/sh
cat >/dev/null
printf '%s' '{"continue":false,"stopReason":"budget exhausted for this turn"}'
{
"decision": {
"feedback_message": "PostToolUse hook stopped execution",
"should_stop": true,
"stop_reason": "budget exhausted for this turn"
},
"status": "blocked",
"exit_code": 0,
"effects": ["stopped", "feedback"]
}

A UserPromptSubmit hook with one snake_case key, which is rejected whole:

hooks/bad.sh
#!/bin/sh
cat >/dev/null
printf '%s' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additional_context":"snake case is rejected"}}'
{
"decision": {},
"status": "failed",
"exit_code": 0,
"error": "unsupported `additional_context` in hookSpecificOutput of UserPromptSubmit hook output",
"effects": []
}

Always drain stdin (cat >/dev/null or payload=$(cat)) even when you do not need the payload, and print JSON with printf '%s' or a JSON library so no stray text precedes the {.

Installing a plugin never activates its hooks. After muse plugins install, muse plugins inspect <plugin-id> lists each hook as a runtime capability with status=review_needed:

runtime-capability plugin:weather:hook:log-tool-use status=review_needed

Read the hook’s script, then approve it, either alone with muse plugins approve <plugin-id>:hook:<hook-id> or together with every other reviewable capability of the plugin with muse plugins approve <plugin-id>. Hooks are wired when a session starts, so the approval takes effect in the next session. Skills and commands do not need review; Trust, review and scopes explains the difference.

Your approval is bound to a hash of the hook’s definition and the installed package. Any change to the package, even to a file the hook does not use, returns the hook to status=modified, and a modified hook does not run until you approve it again. Hooks of a disabled plugin never run.

muse plugins hook test runs one installed, enabled hook against a fixture file, outside any session. The fixture names the event and carries the stdin object the hook should receive:

fixture.json
{
"event": "PostToolUse",
"stdin": {
"hook_event_name": "PostToolUse",
"session_id": "test-session",
"cwd": "/tmp",
"tool_name": "read_file",
"tool_input": { "path": "README.md" },
"tool_response": { "ok": true }
}
}
Fixture key Required Meaning
event Yes The hook’s event, as PostToolUse or post_tool_use. A different event is refused, and the message names both accepted spellings.
stdin Yes The object written to the hook’s stdin, verbatim. hook test adds nothing, so include the common keys yourself when the script reads them.
matcher_input No The tool name a tool-event hook is matched against.
cwd No The working directory of the process. Defaults to your current directory.

Running the weather example’s hook:

Terminal window
muse plugins hook test weather:log-tool-use --fixture fixture.json
hook-test weather:log-tool-use status=completed

With --json you get the run document. This is the complete output for the same run:

{
"decision": {
"should_block": false,
"block_reason": null,
"additional_contexts": [],
"updated_input": null,
"permission_decision": null,
"feedback_message": null,
"should_stop": false,
"stop_reason": null
},
"terminals": [
{
"run_id": "plugin:weather:log-tool-use:1",
"hook_key": "plugin:weather:log-tool-use",
"event": "post_tool_use",
"status_message": "Logging tool use",
"system_message": null,
"status": "completed",
"duration_ms": 244,
"exit_code": 0,
"effects": [],
"stdout": "",
"stderr": "",
"error": null
}
],
"records": 2
}

terminals[0].status is one of completed, blocked, failed, timed_out or cancelled. decision is what the session would have done with the output, and effects names the parts of the output that were applied. A rejected output shows status: "failed" with the diagnostic in error; the command itself still exits 0. The hook ran for real: after this run the weather plugin’s tool-use.log has a new line ending in read_file.

Symptom Cause Fix
status=failed with an unsupported ... diagnostic such as unsupported `additional_context` in hookSpecificOutput A snake_case, misspelled or event-inappropriate field in the JSON output. Use the camelCase names from the reference and only the fields the event allows.
status=failed with a diagnostic that hookSpecificOutput is missing the required hookEventName hookSpecificOutput without hookEventName. Set hookEventName to the event that ran.
status=failed, malformed_output: hook stdout started as JSON but did not parse Debug text or a second line after the JSON, or a trailing shell newline inside a string. Print exactly one JSON object and nothing else.
A deny hook shows status=completed and the tool ran permissionDecision: deny without a permissionDecisionReason, or output on an event other than PreToolUse. Add a non-empty reason; deny only on PreToolUse.
Exit 2 shows status=failed The event cannot block, or stderr was empty. Write the reason to stderr. On SessionStart and PreCompact use continue: false instead; PostCompact, SessionEnd, Notification and StopFailure accept no decision at all.
status=timed_out The script waited on stdin after reading it, or on a network call. Read stdin once, then exit; raise timeoutMs only for work that really takes that long.
An argument such as $MUSE_PLUGIN_ROOT/x arrives literally Argv commands get no substitution. Read the variable inside the script.
No such file or directory when writing to MUSE_PLUGIN_DATA_DIR The directory is advertised, not created. mkdir -p "$MUSE_PLUGIN_DATA_DIR" first.
The hook shows trusted_enabled but never runs Hooks are wired at session start. Start a new session.
The hook went back to modified after muse plugins update The approval hash covers the whole package. Approve it again after each update.