Hooks — explanation
What is this about?
A hook is a command mm runs at fixed moments: before the model reads a question (preGeneration), before and after each tool call (preToolCall, postToolCall), once the turn is over (postGeneration), and around each hand-off to another agent (preAsk, postAsk). 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; ticket 87 added the two hand-off events. 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.
Hooks around agent calls
An agent can hand a question to another one: the model calls ask_agent, the user types /ask-<name>, or a skill runs mm --ask. The asked agent is a separate mm process with its own file, so it runs its own hooks. That covers what belongs to that agent, whoever asks it. It does not cover what belongs to the hand-off: what may leave this agent, for whom, and what this agent does with what comes back. Those rules are the asking agent's author's, and the asked agent's file may not even be theirs to change.
Before ticket 87, the only handle was preToolCall/postToolCall with matcher: ^ask_agent$, and it fell short in four ways: the /ask commands and mm --ask do not go through engine.Generate, so no hook ran for them at all; preToolCall saw the model's arguments but not the conversation with_context would add, which is built inside the tool; postToolCall could only append to an answer already truncated, never replace it; and a failure reached it as text mixed into the output.
preAsk and postAsk therefore plug in at the one place every hand-off goes through, ask.Pool.Delegate: the tool, the commands and the command line all call it. preAsk sees the question and the conversation before they are joined into the prompt the asked agent receives, so it can rewrite either; postAsk sees the whole answer and the error separately, before the answer is truncated into a tool output or kept in the history.
Four decisions, taken with the user:
preAskruns after theask_agentconsent prompt. The user approves what the model asked for; the hook then filters or rewrites it. The other order would have the user approve a question they never see.- No
retryaction. A hook that re-asks would start a second hand-off inside the first, with its own consent and its own budget. When an answer is not good enough,postAsksays so incontext, and the asking model decides whether to ask again — throughask_agent, where every rule applies once more. mm --askruns the hooks of the file it reads, like the other two paths. With apostAskhook it holds the answer back until the hooks have run, because its stdout is what the calling script reads.- No rerouting.
preAskcannot change which agent is asked: the consent prompt and the tool call name the agent the model chose, and a hook silently swapping it would make both untrue.
One limit follows from how the answer travels: it streams to the user while the asked agent writes it. postAsk changes what the asking agent reads and remembers, not what was shown — the same rule as postToolCall. A replaced answer is announced on the notice line so the two never differ unseen.
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; its hooks see"asked":{"depth":<n>}. The asker'spreAskandpostAsksurround the hand-off. See Asking another agent. - 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, Guard the agent with hooks to write one, and Control the hand-offs to other agents with hooks for preAsk and postAsk.
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 |
|