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.
What a plugin hook is
Section titled “What a plugin hook is”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.
The manifest entry
Section titled “The manifest entry”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.
How a hook is run
Section titled “How a hook is run”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"]:
#!/bin/shecho "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.sharg1=$MUSE_PLUGIN_ROOT/literalcwd=/private/tmpstdin={"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 USERdata-dir-exists=noThe 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.
How a hook answers
Section titled “How a hook answers”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:
#!/bin/shcat >/dev/nullprintf '%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:
#!/bin/shcat >/dev/nullecho "tests have not been run yet; run them before stopping" >&2exit 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:
#!/bin/shcat >/dev/nullprintf '%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:
#!/bin/shcat >/dev/nullprintf '%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 {.
Hooks need review
Section titled “Hooks need review”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_neededRead 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.
Testing a hook with a fixture
Section titled “Testing a hook with a fixture”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:
{ "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:
muse plugins hook test weather:log-tool-use --fixture fixture.jsonhook-test weather:log-tool-use status=completedWith --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.
Common mistakes
Section titled “Common mistakes”| 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. |
Next steps
Section titled “Next steps”- Hook events and payloads: every event, its stdin keys, the complete output schema, caps and environment.
- A guard hook that blocks risky tool calls: a
complete
PreToolUseplugin with its test fixtures. - Trust, review and scopes: what approval records and when it is invalidated.
- The muse plugins command:
hook test,approveandinspectin full. - Compatibility with other plugin formats: how hooks from Claude Code and Codex packages are admitted.