bots-garden/mini-mepublic Fork
⑂ v0.8.0
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP
🛟 Updated. f49e056 · on v0.8.0 · k33g · 4d ago
hooks.md · 291 lines · 18.6 KBmarkdown
Blame HistoryOpen raw

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 — (required) Run with sh -c "<command>". Must not be empty or blank.
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

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, 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.

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 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.
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
error all a failure, handled according to onError
anything else, or stop/deny 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)
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.

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>
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 \`: : 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 with mm --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
<decision> continue, stop, deny, 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 no command, or a blank one
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

  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
# 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

```yaml
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 | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. |
| `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](#failures)). |

### Example

```yaml
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
```

## 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, 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](#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](#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](cli.md#environment-variables).

## 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](#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

```json
{"event":"preGeneration","cwd":"/home/me/project","model":"llmman/hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M","prompt":"list the files"}
```

```json
{"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
```

```json
{"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` 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. |
| `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 |
| `error` | all | a failure, handled according to `onError` |
| anything else, or `stop`/`deny` 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) |
| `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.

### `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>` |
| 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 \`<command>\`: <failure>`: 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 with `mm --ask`.

## Display

With `displayHooks: true`, every hook run is shown — a hook that works is otherwise invisible. One line per run:

```text
🪝 <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 |
| `<decision>` | `continue`, `stop`, `deny`, `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

```text
🤖 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` | no `command`, or a blank one |
| `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](../how-to/guard-the-agent-with-hooks.md)
- [Hooks — explanation](../explanation/hooks.md)
- [Control the hand-offs to other agents with hooks](../how-to/control-agent-calls-with-hooks.md)
- The demos: `demos/hooks/` (four Node.js hooks), `demos/hooks-ask/` (`preAsk` and `postAsk` around an expert agent)