bots-garden/mini-mepublic Fork
⑂ feature/go-1.27.3
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. 5b2f986 · on feature/go-1.27.3 · k33g · 7d ago
hooks.md · 86 lines · 10.1 KBmarkdown
Blame HistoryOpen raw

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 preToolCall hook runs once per tool call; node takes 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 a timeout, 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 jq is 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:

  • preAsk runs after the ask_agent consent 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 retry action. 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, postAsk says so in context, and the asking model decides whether to ask again — through ask_agent, where every rule applies once more.
  • mm --ask runs the hooks of the file it reads, like the other two paths. With a postAsk hook it holds the answer back until the hooks have run, because its stdout is what the calling script reads.
  • No rerouting. preAsk cannot 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 messages array. 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 by prompt, context and action.
  • 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 async mode. A logging hook is fast; when it is not, onError: ignore with a short timeout bounds 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 model bash. 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: false are 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_agent or mm --ask is a separate mm process with its own configuration file, so it runs its own hooks, not its asker's; its hooks see "asked":{"depth":<n>}. The asker's preAsk and postAsk surround 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
# 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 `preToolCall` hook runs once per tool call; `node` takes 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 a `timeout`, 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 `jq` is 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:

- **`preAsk` runs after the `ask_agent` consent 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 `retry` action.** 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, `postAsk` says so in `context`, and the asking model decides whether to ask again — through `ask_agent`, where every rule applies once more.
- **`mm --ask` runs the hooks** of the file it reads, like the other two paths. With a `postAsk` hook it holds the answer back until the hooks have run, because its stdout is what the calling script reads.
- **No rerouting.** `preAsk` cannot 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 `messages` array.** 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 by `prompt`, `context` and `action`.
- **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 `async` mode.** A logging hook is fast; when it is not, `onError: ignore` with a short `timeout` bounds 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 model `bash`. 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: false` are 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](yolo-and-permissions.md).
- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's; its hooks see `"asked":{"depth":<n>}`. The asker's `preAsk` and `postAsk` surround the hand-off. See [Asking another agent](asking-another-agent.md).
- **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it.

See the [hooks reference](../reference/hooks.md) for every field, [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one, and [Control the hand-offs to other agents with hooks](../how-to/control-agent-calls-with-hooks.md) for `preAsk` and `postAsk`.