Reference: hooks
The
hooks:block of the configuration file: the events, the JSON a hook reads and writes, the environment it runs in, and what each decision does to the turn.
Configuration
hooks:
preGeneration: [<hook>, …]
preToolCall: [<hook>, …]
postToolCall: [<hook>, …]
postGeneration: [<hook>, …]
preAsk: [<hook>, …]
postAsk: [<hook>, …]
Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No hooks: block = no hook.
Hook entry
| Key | Type | Default | Description |
|---|---|---|---|
command |
string | — | Run with sh -c "<command>". Required unless wasm is given; not both. Must not be blank. |
wasm |
string | "" |
A WebAssembly module (WASI preview 1 command) run by mm in a sandbox instead of a command — see Wasm hooks. Relative to the configuration file; must exist at load. |
read |
list of strings | [] |
wasm only. Folders of the working directory the module may read, mounted under the same name. Relative, inside the working directory (not ., not above it), each listed once across read and write. |
write |
list of strings | [] |
wasm only. Folders of the working directory the module may read and write. Same rules as read. |
matcher |
string (regular expression, Go syntax) | "" (every tool or agent) |
preToolCall and postToolCall: the hook runs for the tools whose name matches. preAsk and postAsk: for the asked agents whose name (its key in mmConfigs) matches. Not allowed on preGeneration and postGeneration. Unanchored: bash also matches my_bash; ^bash$ matches bash alone. |
timeout |
duration | 10s |
How long one run may last before it is killed. 0 = the default. Must be ≥ 0. |
onError |
fail | warn | ignore |
warn |
What a failing run does (see Failures). |
Example
hooks:
preGeneration:
- command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
timeout: 5s
onError: fail
preToolCall:
- matcher: ^(bash|write_file|edit_file)$
command: ./hooks/guard.sh
postGeneration:
- command: python3 "$MM_CONFIG_DIR/hooks/log.py"
onError: ignore
postToolCall:
- wasm: ./hooks/audit.wasm
write: [logs]
onError: ignore
Events
| Event | Runs | Tools concerned | Can |
|---|---|---|---|
preGeneration |
once per question, before the first call to the model | — | rewrite the question, add context, stop the turn |
preToolCall |
before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by matcher |
deny the call, approve it (no consent prompt), replace its arguments |
postToolCall |
after each tool call that ran | built-in and MCP, filtered by matcher |
add context after the tool's output |
postGeneration |
once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only |
preAsk |
in the asking agent, once per hand-off to another agent — the ask_agent tool, /ask, /ask-<name>, /ask-with-context-<name>, mm --ask — before the asked agent is started or asked; for ask_agent, after the call's consent prompt |
the agents of mmConfigs, filtered by matcher |
deny the hand-off, rewrite the question or the conversation sent, add context for the asked agent |
postAsk |
in the asking agent, once the asked agent's turn is over: answered, failed or cancelled; before the answer becomes ask_agent's output or joins the history |
the agents of mmConfigs, filtered by matcher |
replace the answer the asking agent reads, add context after it |
All six run in every front end: the line mode, the window (-tui), an editor (-acp), -p. An agent started by /ask, ask_agent or mm --ask runs the hooks of its own configuration file; preAsk and postAsk are the asking agent's. A preGeneration hook does not run on a context-compression summary.
On the ask_agent path the order is: preToolCall → consent prompt → preAsk → the asked agent (its own hooks) → postAsk → postToolCall. On /ask… and mm --ask: preAsk → the asked agent → postAsk.
Execution
| Aspect | Value |
|---|---|
| Command | sh -c "<command>" |
| Working directory | the session's: the directory mm runs in, or the editor session's cwd over ACP |
| Environment | mm's own, plus the two variables below |
| stdin | one JSON object (Input), then end of file; &, < and > are written as they are (no \u0026 escaping), so a hook can look for && or > in a command with a plain text search |
| stdout | empty, or one JSON object (Reply); at most 1 MiB |
| stderr | quoted (first 300 bytes) in the failure line when the run fails; otherwise ignored |
| Concurrency | hooks of one event run one after the other; preToolCall/postToolCall may run concurrently for two tool calls of one model reply |
Environment variables
| Variable | Value |
|---|---|
MM_HOOK_EVENT |
the event: preGeneration, preToolCall, postToolCall, postGeneration, preAsk or postAsk |
MM_CONFIG_DIR |
absolute directory of the configuration file that declared the hook |
mm's own environment also holds MM_BIN (the path of the running mm) and MM_CONFIG (its configuration file, when one was read), which mm sets for every process it starts — see the command line reference.
Wasm hooks
An entry with wasm: instead of command: reads the same input, writes the same reply and follows the same matcher, timeout and onError. These rows replace the ones above:
| Aspect | Value |
|---|---|
| Module | a fresh instance of the module, run by mm (wazero); no process is started. Compiled once per mm process, on its first run — shared with the wasm tools |
| Environment | MM_HOOK_EVENT only. Not MM_CONFIG_DIR, not mm's own variables |
| Files | only the read folders (read-only) and the write folders (read-write), in the session's working directory, mounted under their own name (logs is logs/… or /logs/… inside the module); any other path does not exist |
| Network | none (WASI preview 1 has none) |
| Clock, randomness | the real clock and sleep; the system's secure random source |
| Memory | at most 256 MiB of linear memory |
| stdout, stderr | as for a command: the reply at most 1 MiB, the first 300 bytes of stderr quoted when the run fails |
Input
The JSON object written on the hook's stdin.
| Field | Type | Events | Description |
|---|---|---|---|
event |
string | all | the event's name |
cwd |
string | all | the session's working directory, absolute |
model |
string | all | the model reference, <provider>/<model> |
prompt |
string | preGeneration, postGeneration |
the question as typed — @path and $skill lines included. In a chain, the question as the previous hook left it. In postGeneration: the question the model read, or the marker [prompt blocked by a hook: <reason>] when a hook stopped the turn. |
tool |
object | preToolCall, postToolCall |
the tool call: id (the call's reference, may be empty), name, input (the arguments, as the model sent them or as the previous hook replaced them) |
ask |
object | preAsk, postAsk |
the hand-off — see The ask object |
output |
string | postToolCall |
the tool's output, as the model will read it (JSON when the tool returns something other than text) |
answer |
string | postGeneration, postAsk |
postGeneration: the text of the last answer. postAsk: the asked agent's whole answer, before any truncation, or as the previous hook replaced it. Empty when there is none. |
error |
string | postGeneration, postAsk |
the turn's error, or the hand-off's; absent when it succeeded |
durationMs |
number | postAsk |
how long the asked agent took, in milliseconds |
asked |
object | all | present only in an agent that was itself asked by another one: {"depth": <n>}, 1 for an agent asked by the one a person started |
Empty fields are omitted.
The ask object
| Field | Type | Description |
|---|---|---|
agent |
string | the asked agent's name, its key in mmConfigs |
config |
string | the asked agent's configuration file, absolute |
agentModel |
string | <provider>/<model> read from that file; absent when it cannot be read |
via |
string | tool (the ask_agent tool), command (/ask, /ask-<name>, /ask-with-context-<name>) or cli (mm --ask, mm --ask-with-context) |
withContext |
boolean | whether the conversation is sent along |
depth |
number | the asking agent's depth: 0 for the agent a person started |
prompt |
string | the question. In a preAsk chain, as the previous hook left it; in postAsk, as it was sent. |
transcript |
string | the conversation sent along (withContext only), in the same way; absent otherwise |
Examples
{"event":"preGeneration","cwd":"/home/me/project","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"}
{"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
{"event":"postAsk","cwd":"/home/me/project","model":"llmman/…","ask":{"agent":"expert","config":"/home/me/project/agent.expert.yaml","agentModel":"llmman/…","via":"tool","withContext":false,"depth":0,"prompt":"What is an inode?"},"answer":"An inode is …","durationMs":25374}
Reply
The JSON object a hook may write on stdout. An empty stdout (or whitespace only) is {"action":"continue"}. Every field is optional.
| Field | Type | Read on | Description |
|---|---|---|---|
action |
string | all | continue (default, also when absent), stop, deny, allow or error — see below |
prompt |
string | preGeneration, preAsk |
replaces the question — the user's, or the one sent to the asked agent. Absent = unchanged; "" = an empty question. |
transcript |
string | preAsk |
replaces the conversation sent to the asked agent. Absent = unchanged; "" = send none. |
answer |
string | postAsk |
replaces the answer the asking agent reads. Absent = unchanged. |
context |
string | preGeneration, postToolCall, preAsk, postAsk |
text for a model. preGeneration: appended to the question. postToolCall: appended to the tool's output. preAsk: appended to what the asked agent receives. postAsk: appended to the answer the asking agent reads. Each one is introduced by the line [context added by a hook]. Blank = ignored. |
input |
any JSON value | preToolCall |
replaces the tool call's arguments. Absent = unchanged. |
reason |
string | preGeneration (stop), preToolCall and preAsk (deny) |
why. Ignored on allow: write it anyway, it documents the hook's verdict for whoever reads its output. |
response |
string | preGeneration (stop) |
what the user reads instead of an answer. Absent or blank: ⛔ This request was blocked by a hook: <reason>. |
error |
string | all (error) |
the hook's own failure message. |
Fields read on another event are ignored.
Actions
| Action | Allowed on | Effect |
|---|---|---|
continue |
all | the turn goes on, with this hook's prompt, context and input applied |
stop |
preGeneration |
the turn ends without calling the model; the following hooks of the event do not run |
deny |
preToolCall, preAsk |
the tool does not run, or the asked agent is not started nor asked; the following hooks of the event do not run |
allow |
preToolCall |
the call runs without the consent prompt, as if the user had answered allow once — see preToolCall; the following hooks of the event still run, and a deny among them wins |
error |
all | a failure, handled according to onError |
anything else, or stop/deny/allow on another event |
— | a failure: action "<action>" is not valid for <event> |
Effects on the turn
preGeneration
| Decision | The model reads | The history keeps | The user sees |
|---|---|---|---|
| no change | the question | the question | — |
prompt and/or context |
the rewritten question, then each context | the rewritten question (the front end's own copy is not changed during the turn) | — |
stop |
nothing: the model is not called | [prompt blocked by a hook: <reason>] (or [prompt blocked by a hook]) followed by the response as the answer |
the response, as the answer |
A chain: each hook reads the prompt the previous one left; contexts accumulate in hook order.
preToolCall
| Decision | The tool | The model reads | The user sees |
|---|---|---|---|
| no change | runs with the model's arguments | the output | the usual tool line |
input |
runs with the replaced arguments | the output | the usual tool line (with the replaced arguments) |
allow |
runs without asking for consent — with yolo: false, and in mm -p where nobody can be asked |
the output | the usual tool line, no consent prompt |
deny |
does not run, and does not ask for consent | [denied by a hook: <reason>] as the tool's output |
line mode: 🪝 <tool> [denied by a hook: <reason>]; window and editor: a failed tool call titled <tool>: <command, path, URL or agent, 60 characters at most> — denied by a hook (<tool> — denied by a hook when the input has none of these) |
The history keeps the tool call as the model wrote it, even when input replaced its arguments.
allow only skips a question; it never overrules an answer. A never the user gave for the tool earlier in the session still refuses the call. In an agent asked by another (ask_agent, /ask…, mm --ask), allow is ignored: the asking agent's user decides there. With yolo: true nothing is asked anyway, and allow changes nothing.
postToolCall
context from every matching hook is joined and appended to the output: <output>\n\n[context added by a hook]\n<context>. The tool's output shown to the user is unchanged.
postGeneration
No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored.
preAsk
| Decision | The asked agent | The user sees |
|---|---|---|
| no change | receives the question (preceded by the conversation with withContext) |
the usual hand-off |
prompt, transcript, context |
receives the rewritten question and conversation, then each context | the usual hand-off |
deny |
is not started nor asked | ask_agent: line mode 🪝 ask_agent [ask denied by a hook: <reason>], window and editor a failed ask_agent call; the model reads [ask denied by a hook: <reason>]. /ask…: the command fails with ask denied by a hook: <reason>, the history is unchanged. mm --ask: [ask: ask denied by a hook: <reason>] on stderr, exit code 1. |
A chain: each hook reads the prompt and transcript the previous one left; contexts accumulate in hook order. With /ask…, the history keeps the question as sent.
postAsk
| Decision | The asking agent reads | The user sees |
|---|---|---|
| no change | the answer (ask_agent's output, or the [<agent>'s answer] message of /ask…) |
the answer, as it streamed |
answer |
the replacement | the answer as it streamed, then the notice 🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one; ask_agent's call summary reads <agent> answered; a hook replaced the answer (<n> characters) |
context |
the answer, then [context added by a hook] and each context |
the answer, as it streamed |
The answer streams to the user while the asked agent writes it: postAsk changes what the asking agent reads and keeps, never what was shown. An answer on a failed hand-off (not a cancelled one) replaces the failure: the asking agent reads it as the answer, and /ask… keeps the exchange.
With at least one postAsk hook declared, mm --ask holds the answer back and writes it to stdout once the hooks have run (replacement and context included); without one, it streams the answer as before.
Failures
A run fails when:
| Failure | Report |
|---|---|
| the command exits with a non-zero status | exit status <n>: <stderr> |
| a wasm module exits with a non-zero code | exit code <n>: <stderr> |
| a wasm module cannot be loaded | <file>.wasm cannot be loaded: <reason> |
| one of a wasm module's folders does not exist | the folder <f> this module may see does not exist in <dir> |
it runs longer than timeout |
timed out after <timeout> |
the turn is cancelled while it runs (preGeneration, preToolCall, postToolCall, preAsk) |
cancelled |
| stdout is larger than 1 MiB | stdout larger than 1048576 bytes |
| stdout is not JSON | invalid JSON on stdout: … |
action is error |
the reply's error, or the hook reported an error |
action is unknown or not allowed on the event |
action "<action>" is not valid for <event> |
postGeneration and postAsk are not cancelled with the turn: they run after a cancellation too (Escape, Ctrl-C, /abort), bounded by their timeout.
What follows depends on onError:
onError |
preGeneration |
preToolCall, preAsk |
postToolCall, postGeneration, postAsk |
|---|---|---|---|
fail |
report line, turn stopped (response: ⛔ This request was blocked by a hook: hook failed: …) |
report line, call or hand-off denied (reason: hook failed: …) |
report line, carry on |
warn |
report line, next hook | report line, next hook | report line, carry on |
ignore |
next hook, silently | next hook, silently | carry on, silently |
The report line is 🪝 <event> hook \`: — for a wasm hook, the module's file name in place of the command: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr withmm --ask`.
Display
With displayHooks: true, every hook run is shown — a hook that works is otherwise invisible. One line per run:
🪝 <n>. <event> [<tool or agent>] · <command> → <decision>
| Part | Value |
|---|---|
<n> |
the run's number in the turn, from 1 (line mode, -p, window) |
<tool or agent> |
the tool's name (preToolCall, postToolCall) or the asked agent's mmConfigs name (preAsk, postAsk); absent on preGeneration and postGeneration |
<command> |
the hook's command, as written in the configuration; for a wasm hook, the module's file name (bash_guard.wasm) |
<decision> |
continue, stop, deny, allow, failed (the run failed; its onError decided what followed), or what the reply changed, joined by +: prompt replaced, transcript replaced, answer replaced, input replaced, context (<n> chars) |
Only the fields the event reads count: a context returned to postGeneration is shown as continue. The text of a context is never shown, only its length in characters.
| Front end | Where |
|---|---|
line mode, -p |
after the answer, under the ⚙ line (and the displayCommands list), in the same colour |
window (-tui) |
one notice per run under the ⚙ line |
editor (-acp) |
one tool call per run, kind other, titled 🪝 <event> … → <decision> (no number), sent when the hook ends and completed at once; failed when the run failed. Not part of the history: not replayed by session/load. |
The hooks that run outside a turn (/ask in the line mode or the window) are not listed. mm --ask and a structuredOutput agent's -p list nothing.
Example
🤖 The capital of France is Paris.⚙ 0 command(s)
🪝 1. preGeneration · node "$MM_CONFIG_DIR/hooks/guards.js" → context (178 chars)
Start-up banner
When at least one hook is declared, the banner ends with | hooks: <event>×<count> …, one entry per event that has hooks, in the order preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk:
mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
Validation errors
Reported at start-up as [config error: <path>: <message>], exit code 1.
| Message | Cause |
|---|---|
hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk) |
a key of hooks: that is not an event (a typo: preToolcall) |
hooks.<event>[N]: command must not be empty (or wasm: a module) |
no command and no wasm, or a blank command |
hooks.<event>[N]: give command or wasm, not both |
an entry with both |
hooks.<event>[N]: wasm: no module at <path> |
the module file does not exist, or is a folder |
hooks.<event>[N]: read and write only apply to a wasm hook — … |
read or write on a command entry |
hooks.<event>[N]: folder "<f>" must be relative to the working directory |
an absolute folder |
hooks.<event>[N]: folder "<f>" must be a folder inside the working directory, not it or above it |
., .. or a path that leads above |
hooks.<event>[N]: folder "<f>" is given twice |
a folder listed twice |
hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk |
a matcher on preGeneration or postGeneration |
hooks.<event>[N]: matcher: error parsing regexp: … |
a matcher that does not compile |
hooks.<event>[N]: timeout must be >= 0 |
a negative timeout |
hooks.<event>[N]: onError must be "fail", "warn" or "ignore" |
any other onError |
See also
- Guard the agent with hooks
- Hooks — explanation
- Control the hand-offs to other agents with hooks
- The demos:
demos/hooks/(four Node.js hooks),demos/hooks-ask/(preAskandpostAskaround an expert agent)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
|