Hooks — explanation
What is this about?
A hook is a command mm runs at four moments of a turn: before the model reads a question (preGeneration), before and after each tool call (preToolCall, postToolCall), and once the turn is over (postGeneration). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing mm's code.
The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out.
Why external commands
The ticket's first requirement was that hooks can be written in any language. That rules out a Go plugin interface (it would need a recompilation, and Go's plugin package does not exist on every platform mm is built for) and an embedded interpreter (it would pick one language for everyone). A process reading stdin and writing stdout is the one interface every language has.
It has costs, and they were accepted on purpose:
- A process start per event. A
preToolCallhook runs once per tool call;nodetakes some tens of milliseconds to start. Against a local model that spends seconds per answer, this is noise. It is also why every hook has atimeout, and why the default is short (10 s): a hook sits in the middle of the turn, and a slow one is felt at once. - JSON as the contract. The input and the reply are small and flat, so that a hook in shell with
jqis as easy to write as one in Python or Node.
The same shape is used elsewhere in the ecosystem (editor and agent hooks, git hooks with their arguments): someone who has written one recognises the other.
Where they plug in
Every front end — the line mode, the window, the ACP façade, -p — hands its turn to one function, engine.Generate. And inside it, Genkit's middleware already has a WrapTool hook that sees every tool call, built-in or MCP, before it runs. Those two points are where the hooks run:
front end ──▶ engine.Generate
├─ preGeneration (the question, before the first model call)
├─ model ⇄ tools ── WrapTool
│ ├─ preToolCall (before the tool, and its consent prompt)
│ └─ postToolCall (after the tool)
└─ postGeneration (the turn is over, whatever its outcome)
Placing them there rather than in each front end means one implementation, one set of tests, and no front end that forgets them. A new front end gets the hooks for free.
preToolCall runs before the tool's consent prompt. A denied call therefore never asks the user anything; and a guard keeps working with yolo: true, which is when it matters most.
The package internal/hooks runs the commands and turns their replies into decisions; it knows nothing about Genkit or the front ends. internal/engine/hooks.go applies those decisions to the messages and shows them: as the answer, as a failed tool call, or as a notice — a line in the terminal, a dim line in the window, the editor's logs over ACP.
Decisions worth knowing
A stopped question leaves a marker, not the question. The obvious reason to stop a question is that it holds something that must not reach the model: a password, a key. Keeping it in the history would send it on the next turn. The history therefore keeps [prompt blocked by a hook: <reason>] followed by the hook's response — enough for the model to understand what happened, nothing it must not read. The first live run of the demo found a second leak: the audit hook on postGeneration received the blocked question and wrote the secret to its log. The post hooks now read the marker too.
Failures follow onError, and the default is warn. A hook that crashes is shown, and the turn goes on: a broken logging script must not lock the user out of their agent. A guard should say onError: fail — a guard that cannot run must not let the call through. The two needs are opposite, so the choice is per hook.
An unknown event name is an error. YAML ignores keys it does not know; preToolcall: would have been a guard that silently never runs. Refusing to start is the safer failure.
A denied tool call answers the model as text. Like every tool failure in mm, the refusal is the tool's output: [denied by a hook: <reason>]. The model can tell the user or try another way, rather than the turn ending on an error.
postGeneration is not cancelled with the turn. An audit log that loses the turns the user interrupted is the wrong way round. The hook runs after a cancellation too, bounded by its own timeout.
A replaced tool input is not written back into the history. The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later.
Rejected alternatives
Ticket 36 explored more than what was built. What was left out, and why:
- Returning the whole
messagesarray. The ticket's first protocol let a hook rewrite the conversation. It is fragile — a hook could drop the system prompt or rewrite past turns, and every hook would have to round-trip the whole history — and the four needs (detect, rewrite, add context, stop) are covered byprompt,contextandaction. - Numeric priorities. The order of the YAML list is the order of execution. A priority field adds a second ordering to keep in mind for no gain when the list is short.
- An
asyncmode. A logging hook is fast; when it is not,onError: ignorewith a shorttimeoutbounds it. A background mode would need a queue, a shutdown flush and a way to report late failures. allowedPaths/allowedExtensions. The YAML file already starts MCP servers and gives the modelbash. Whoever writes it is trusted with far more than a hook path; an allowlist would protect nothing.- Lifecycle, session and front-end hooks (start-up, configuration loaded, shutdown, window opened, editor connected). No use case asked for them yet. They can be added as events later without changing the protocol.
How it relates to the rest
- Yolo and consent. Hooks and
yolo: falseare complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See Yolo mode and permissions. - Asking another agent. An agent started by
/ask,ask_agentormm --askis a separatemmprocess with its own configuration file, so it runs its own hooks, not its asker's. - Context compression asks the model for a summary through another path than
Generate; the hooks do not run on it.
See the hooks reference for every field, and Guard the agent with hooks to write one.
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 |
|