bots-garden/mini-mepublic Fork
⑂ feature/hooks
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. 46087c3 · on feature/hooks · k33g · 8d ago
hooks.md · 69 lines · 7.0 KBmarkdown
Blame HistoryOpen raw

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

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.
  • 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
# 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 `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.

## 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.
- **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, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one.