bots-garden/mini-mepublic Fork
⑂ feature/wasm-custom-tools
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. fd5d2a7 · on feature/wasm-custom-tools · k33g · yesterday
declared-tools.md · 66 lines · 8.5 KBmarkdown
Blame HistoryOpen raw

Declared tools — explanation

What is this about?

mm had two ways to let the model run a command of yours before ticket 88: a skill that says "call your bash tool with this command", and an MCP server exposing a tool. Declared tools are a third: a tools: entry in the agent file that turns one command into a typed tool, with no server to write. This page explains why it was added beside skills rather than instead of them, and why it is shaped the way it is.

Why skills were not enough

A skill runs a command in two steps. The model must first find the skill in the catalogue and call read_skill, then compose a bash command line from what it read. Each step can fail, and on a small model they do:

  • The skill is never found. The catalogue is routed by the model reading names, descriptions and keywords. Measured on mellum2-12b, a status skill ("Report the status of one of the company's services") was never read for "What is the status of the billing service?" — 0 out of 10 runs, in English and in French. The model improvised curl localhost:8080/health and ps aux | grep billing, and then answered that the service "seems to be running". That is a wrong answer, not just a miss.
  • The command is rewritten. When the skill is skipped but the model finds the script anyway, it guesses the arguments: ./pkgtest.sh greet instead of ./greet.
  • Consent is all or nothing. Everything a skill runs goes through bash, so "always allow this script" can only be said as "always allow bash".

A declared tool removes the routing step: its name, description and parameters are in every request, like bash's.

What was measured

Three actions (release <version> <channel>, pkgtest <package>, status <service>), each asked in English and in French, 5 runs each, mm -p, on two models. A run succeeds when the script really ran with exactly the expected arguments — checked from a file the script writes, not from the model's answer.

Model Skill + bash Typed tool through an MCP stand-in Declared tool
mellum2-12b 15/30 (4.5 s on average) 25/30 (2.8 s) 30/30 (1.3 s)
qwen3.5-4b 30/30 (12.7 s) 30/30 (7.7 s) 30/30 (5.8 s)

The MCP stand-in was measured first, before any code was written: a small MCP HTTP server exposing the same three tools gave the same comparison without building anything. It decided the question. The declared tools were measured again once built.

Two things stand out. First, a typed tool is faster even when the skill works: one model round trip instead of two. Second, the stand-in's five failures on mellum2 — the French release request — came from the model packing every argument into one field: {version: "1.4.2 1.4.2, channel beta"}. With the declared tool, the same request succeeded 5 times out of 5, each time in one call. The declared tool differs from the stand-in in several ways at once — no proj_ name prefix, no "MCP tool from server …" in the description, a closing "Give each parameter as its own field." — and which of them matters was not measured separately.

The numbers come from one small fixture, two models and five runs per case. They answer "is it worth building?", not "how much better is it in general?".

Why it is designed this way

No shell. The command is a list, one argv entry per element, and a value replaces its {{placeholder}} inside one entry. A model's value — or a value a prompt injection put in the conversation — cannot add an argument, chain a second command or redirect an output: 1.4.2; rm -rf ~ reaches the script as one odd version string. A template like command: "./release.sh {{version}}" run by bash -c was rejected for exactly that reason. A command that needs a pipe goes into a script.

The program cannot come from a parameter. Otherwise a declared tool is bash with extra steps.

Invalid calls are answered, not rejected by the schema. The schema the model reads carries the types, but not required nor enum as keywords — those go into the descriptions. Genkit validates a call against the declared schema before running the tool, and a call it rejects ends the turn. mm prefers to answer in text, saying what was wrong, what the model sent and what each parameter should be, so the model can try again. The same choice was made for MCP tools. Quoting the call back matters: told only "channel is missing", mellum2 sent the same packed call six times until the loop detector stopped the turn.

Lenient coercion, nothing more. Models write "3" for 3 and "true" for true often enough that those strings are accepted. Anything that would need a guess ("yes", "three") is refused with the reason.

Consent follows yolo. A declared tool runs something, so with yolo: false it asks like bash; with yolo: true it does not. Asking "even in yolo" was considered and left out: the agent's author wrote the command, which is already a stronger statement than a model composing a bash line.

Names are reserved early. A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error.

Wasm tools

A command tool runs with all the user's rights: the agent's author wrote the command, but whatever it does, it can do anywhere. A tool that only needs to read one folder, or to compute something from its arguments, does not need that. A wasm: entry (ticket 119) runs a WebAssembly module inside mm instead, with wazero, and the module starts with nothing: no file, no environment variable, no network. The entry gives it what it needs, folder by folder — read: or write: — and that list is the whole of its world. A model's value, even one a prompt injection planted, cannot reach past it: read_note called with ../agent.yaml in the demo finds no such file, because nothing above notes/ is there to be found.

Why a WASI command, with JSON on stdin. The module is an ordinary program with a main — what TinyGo builds with -target=wasip1 — so writing one needs no SDK and no exported functions to learn. The arguments arrive as one JSON object, already checked and typed by mm, the shape the hooks already use; an argv template like command: was the other choice, rejected because it turns every value back into text the module must parse again. This contract is the one a module author relies on, so it is meant to stay.

Why wazero. It is pure Go: mm stays one static, CGO-free binary on every release target, with nothing to install beside it. The cost is size: the mm binary grew from 38.97 MB to 42.87 MB (linux/arm64).

What it costs at run time, measured on the sandbox where it was built (linux/arm64): compiling a TinyGo module of about 550 KB took 176–183 ms, once per mm process, on its first call; each call after that, a fresh instance, took 0.2–0.5 ms. For comparison, ticket 46 measured a Node.js hook at about 62 ms per call. A fresh instance per call means no state survives between calls, and calls can overlap.

What it does not do. WASI preview 1 has no network, so a tool that calls an API stays a command (or an MCP tool). Consent is the same as for a command (yolo): the sandbox limits what a module can touch, but the decision to run it is still the user's. Only TinyGo modules were tried.

Rejected alternatives

  • Only skills. Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets.
  • Only MCP. Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments".
  • A general Wasm plugin system (ticket 46) — hooks, providers, anything as a plugin. Only declared tools became Wasm (ticket 119, above): one entry, one module, one documented contract. Hooks as Wasm modules remain an open idea there.

How it relates to the rest

Declared tools sit beside the built-in tools: the same loop detector, the same output truncation and echo, the same working directory and cancellation as bash, the same permission dialogs (see yolo and permissions), and the preToolCall / postToolCall hooks. Skills remain the place for know-how and procedures; a skill can also tell the model to call a declared tool.

 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
# Declared tools — explanation

## What is this about?

mm had two ways to let the model run a command of yours before ticket 88: a [skill](../how-to/add-a-skill.md) that says "call your `bash` tool with this command", and an MCP server exposing a tool. Declared tools are a third: a `tools:` entry in the agent file that turns one command into a typed tool, with no server to write. This page explains why it was added beside skills rather than instead of them, and why it is shaped the way it is.

## Why skills were not enough

A skill runs a command in two steps. The model must first find the skill in the catalogue and call `read_skill`, then compose a `bash` command line from what it read. Each step can fail, and on a small model they do:

- **The skill is never found.** The catalogue is routed by the model reading names, descriptions and keywords. Measured on mellum2-12b, a `status` skill ("Report the status of one of the company's services") was never read for "What is the status of the billing service?" — 0 out of 10 runs, in English and in French. The model improvised `curl localhost:8080/health` and `ps aux | grep billing`, and then answered that the service "seems to be running". That is a wrong answer, not just a miss.
- **The command is rewritten.** When the skill is skipped but the model finds the script anyway, it guesses the arguments: `./pkgtest.sh greet` instead of `./greet`.
- **Consent is all or nothing.** Everything a skill runs goes through `bash`, so "always allow this script" can only be said as "always allow bash".

A declared tool removes the routing step: its name, description and parameters are in every request, like `bash`'s.

## What was measured

Three actions (`release <version> <channel>`, `pkgtest <package>`, `status <service>`), each asked in English and in French, 5 runs each, `mm -p`, on two models. A run succeeds when the script really ran with exactly the expected arguments — checked from a file the script writes, not from the model's answer.

| Model | Skill + `bash` | Typed tool through an MCP stand-in | Declared tool |
|-------|----------------|-------------------------------------|---------------|
| mellum2-12b | 15/30 (4.5 s on average) | 25/30 (2.8 s) | 30/30 (1.3 s) |
| qwen3.5-4b | 30/30 (12.7 s) | 30/30 (7.7 s) | 30/30 (5.8 s) |

The MCP stand-in was measured first, before any code was written: a small MCP HTTP server exposing the same three tools gave the same comparison without building anything. It decided the question. The declared tools were measured again once built.

Two things stand out. First, a typed tool is faster even when the skill works: one model round trip instead of two. Second, the stand-in's five failures on mellum2 — the French release request — came from the model packing every argument into one field: `{version: "1.4.2 1.4.2, channel beta"}`. With the declared tool, the same request succeeded 5 times out of 5, each time in one call. The declared tool differs from the stand-in in several ways at once — no `proj_` name prefix, no "MCP tool from server …" in the description, a closing "Give each parameter as its own field." — and which of them matters was not measured separately.

The numbers come from one small fixture, two models and five runs per case. They answer "is it worth building?", not "how much better is it in general?".

## Why it is designed this way

**No shell.** The command is a list, one argv entry per element, and a value replaces its `{{placeholder}}` inside one entry. A model's value — or a value a prompt injection put in the conversation — cannot add an argument, chain a second command or redirect an output: `1.4.2; rm -rf ~` reaches the script as one odd version string. A template like `command: "./release.sh {{version}}"` run by `bash -c` was rejected for exactly that reason. A command that needs a pipe goes into a script.

**The program cannot come from a parameter.** Otherwise a declared tool is `bash` with extra steps.

**Invalid calls are answered, not rejected by the schema.** The schema the model reads carries the types, but not `required` nor `enum` as keywords — those go into the descriptions. Genkit validates a call against the declared schema before running the tool, and a call it rejects ends the turn. mm prefers to answer in text, saying what was wrong, what the model sent and what each parameter should be, so the model can try again. The same choice was made for MCP tools. Quoting the call back matters: told only "channel is missing", mellum2 sent the same packed call six times until the [loop detector](../reference/tools.md#loop-detection) stopped the turn.

**Lenient coercion, nothing more.** Models write `"3"` for 3 and `"true"` for true often enough that those strings are accepted. Anything that would need a guess (`"yes"`, `"three"`) is refused with the reason.

**Consent follows `yolo`.** A declared tool runs something, so with `yolo: false` it asks like `bash`; with `yolo: true` it does not. Asking "even in yolo" was considered and left out: the agent's author wrote the command, which is already a stronger statement than a model composing a `bash` line.

**Names are reserved early.** A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error.

## Wasm tools

A command tool runs with all the user's rights: the agent's author wrote the command, but whatever it does, it can do anywhere. A tool that only needs to read one folder, or to compute something from its arguments, does not need that. A `wasm:` entry (ticket 119) runs a WebAssembly module inside mm instead, with [wazero](https://wazero.io), and the module starts with **nothing**: no file, no environment variable, no network. The entry gives it what it needs, folder by folder — `read:` or `write:` — and that list is the whole of its world. A model's value, even one a prompt injection planted, cannot reach past it: `read_note` called with `../agent.yaml` in the demo finds no such file, because nothing above `notes/` is there to be found.

**Why a WASI command, with JSON on stdin.** The module is an ordinary program with a `main` — what TinyGo builds with `-target=wasip1` — so writing one needs no SDK and no exported functions to learn. The arguments arrive as one JSON object, already checked and typed by mm, the shape the hooks already use; an argv template like `command:` was the other choice, rejected because it turns every value back into text the module must parse again. This contract is the one a module author relies on, so it is meant to stay.

**Why wazero.** It is pure Go: mm stays one static, CGO-free binary on every release target, with nothing to install beside it. The cost is size: the mm binary grew from 38.97 MB to 42.87 MB (linux/arm64).

**What it costs at run time**, measured on the sandbox where it was built (linux/arm64): compiling a TinyGo module of about 550 KB took 176–183 ms, once per mm process, on its first call; each call after that, a fresh instance, took 0.2–0.5 ms. For comparison, ticket 46 measured a Node.js hook at about 62 ms per call. A fresh instance per call means no state survives between calls, and calls can overlap.

**What it does not do.** WASI preview 1 has no network, so a tool that calls an API stays a command (or an MCP tool). Consent is the same as for a command (`yolo`): the sandbox limits what a module can touch, but the decision to run it is still the user's. Only TinyGo modules were tried.

## Rejected alternatives

- **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets.
- **Only MCP.** Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments".
- **A general Wasm plugin system** (ticket 46) — hooks, providers, anything as a plugin. Only declared tools became Wasm (ticket 119, above): one entry, one module, one documented contract. Hooks as Wasm modules remain an open idea there.

## How it relates to the rest

Declared tools sit beside the built-in tools: the same loop detector, the same output truncation and echo, the same working directory and cancellation as [`bash`](../reference/tools.md#bash), the same permission dialogs (see [yolo and permissions](yolo-and-permissions.md)), and the `preToolCall` / `postToolCall` [hooks](hooks.md). Skills remain the place for know-how and procedures; a skill can also tell the model to call a declared tool.