Skip to main content
Glama

ai-henchmen

An MCP server that lets a big model hand work down to cheaper workers. It has two kinds, told apart by one test: who decides what happens next?

  • Minions do one bounded step the caller chose (classify, extract, summarize, check a claim) on small, mostly local models. They pick no next step, and their output is checked in code. Most of this README is about them.

  • Agents own a goal and pick their own steps (which file to read, what to edit, when to run the tests). Their authority is proposals only: they work in a throwaway copy of a repo and hand back a patch. See Agents.

The chain of command is human, then agents, then minions. The project was called mcp-minions until it gained agents.

Minions

The minion tools give narrow, well-defined jobs to small models instead of a big one: classify text, extract fields into a JSON Schema, check a claim against a source, tag, and summarize. By default the model runs on your own machine through Ollama or LM Studio, so the text never leaves it.

An optional roster adds more models, on this machine or others you reach over the network, with voting and a judge. It can also list hosted cloud models. Those are used only when the caller marks the text as public (sensitive=false); every call is private by default, and the *_file tools always stay local. With the *_file tools the calling model passes a path and never reads the text itself.

The idea is "strong system, slim model": a small model is fine at reading and rewriting, so the server gives it one clear task, forces structured output, and validates the result in code before returning it. It does not trust the model to follow the format. How far the check goes depends on the tool: a label must be one of yours; extracted values and grounding quotes must occur in the source; and every number, date and capitalised name in a summary must occur in the source too. The summary check is a net for invented details, not proof that a summary is fair or complete.

Related MCP server: ollama-handoff

Tools

Tool

What it does

classify(text, labels, hint="")

Picks exactly one of the given labels (1 to 50, unique). Returns {"label": ...}.

extract(text, schema, hint="")

Fills an object matching your JSON Schema ("type": "object"). Returns the object. Every value must occur in the text, or the call fails; see Value check.

summarize(text, max_words=100)

Summary in at most max_words words (5 to 1000). Returns {"summary": ...}. Numbers, dates and capitalised names in it must occur in the text, or the call fails; see Content check.

summarize_url(url, max_words=100, sensitive=true)

Fetch one public http(s) page and summarize it, with summarize's content check. Returns {"summary", "url", "truncated"}. Refuses local, private and internal addresses (MINION_REFUSED_ADDRESS). See Fetching a page.

tag(text, labels, max_labels=3, hint="")

Zero or more of the given labels (at most max_labels). Returns {"labels": [...]}. The schema sent to the backend has no uniqueItems (LM Studio's MLX models reject it with HTTP 400); a repeated label is rejected in code as MINION_SCHEMA, not deduped.

find_sensitive(text)

Personal or secret-looking spans (names, emails, phones, ids, addresses, keys): patterns plus a model. Every span the model names must occur verbatim in the text, or the call fails. Returns {"found", "spans": [{text, kind, by}]}.

scan_diff(folder, range, chunk_chars=3000)

find_sensitive over what a git commit range adds (added lines and commit messages), in chunks, before a push. Findings per chunk with file:line; a failing chunk does not fail the scan. Always local only. See Diff scan.

same(a, b, hint="")

Do two texts state the same facts? same, different or partly, with a phrase quoted from one of the texts as evidence.

grounded(claim, source, hint="")

Is the claim backed by the source? supported, contradicted or unclear, with a quote copied from the source. A verdict whose quote is not in the source becomes unclear. See Grounding check.

check_claims(text, source, hint="")

Splits text into claims and marks each supported or not against source. A supported claim carries a quote found in the source (case, punctuation and number separators ignored; a quote not found downgrades that claim to unsupported with flag: quote_not_found) and may use only numbers and names the source has; a claim of the text that was left out fails the call. Trial tool: see docs/tool-trials.md.

commit_message(diff, hint="")

Drafts {"subject", "body"} for a git diff (up to 12000 characters). The subject is at most 72 characters, imperative and without a final period; every file the message names must be in the diff. Trial tool: see docs/tool-trials.md.

commit_message_staged(folder, hint="")

Same as commit_message, but reads the staged diff of a git work tree itself (git diff --cached, one fixed read: no shell, no caller options, binary files skipped and listed, a diff over 12000 characters or an empty stage refused). Returns {"subject", "body", "files", "skipped"}. Always local (no sensitive parameter). Trial tool: see docs/tool-trials.md.

privacy_class_of(path)

Privacy class (low, personal or restricted) of a path, from the optional map file MINION_PRIVACY_MAP (default ~/.config/minions/privacy-map.json, format {"version": 1, "default": "personal", "paths": [{"path": "/absolute/path", "class": "low"}]}). Script only, no model: real path, longest match on path boundaries, unlabelled = the map default. Returns {"path", "class", "matched", "map"}. The map can only tighten: plain-text tools take an optional source_path, path-aware tools check their own path, and a non-low source keeps the call local whatever sensitive says. No map file changes nothing; a corrupt map treats everything as personal. Trial tool: see docs/tool-trials.md.

route_task(task, sensitive=true)

Advises which worker should take a task: minion_tool, script, haiku, sonnet, top_model or ask_human, with a confidence. Returns {"route", "confidence", "model_route", "escalated", "reason", "model"}. A decision model (or a fast one) picks; code checks it is a valid route; low confidence, a bad reply or a failed call goes up to top_model, never down. Advice only, nothing is run; the only input is the task text. Borrows the classify route. Trial tool: see docs/tool-trials.md.

commit_message_multi(diff, hint="", sensitive=True)

commit_message for a large diff (up to 150000 characters, 30 files). The diff text is split by file in code; lock, binary, deleted and mode-only files are summarised by code, the rest piece by piece by the model; one more call writes {"subject", "body"} from the summaries, checked like commit_message against the whole diff. Also returns files and steps. A diff over the limits is refused, never cut. No read beyond its argument. Trial tool: see docs/tool-trials.md.

score(text, criterion, hint="")

Rates a text 1 to 5 on a stated criterion. Returns {"score", "quote"}: an integer in range, and a quote that occurs in the text. Trial tool: see docs/tool-trials.md.

translate(text, target="en")

Swedish to English (en) or English to Swedish (sv). Every number and capitalised name of the source must survive and none may be added; a place with an English form (Goteborg, Gothenburg) counts as a changed name. Trial tool: see docs/tool-trials.md.

classify_batch(items, labels, ...), tag_batch(items, labels, max_labels=3, ...)

classify or tag for up to 50 items [{"id", "text"}], one result per id; a failing item does not fail the batch. See Batches.

classify_file, extract_file, summarize_file

Same, but reading UTF-8 text from a local file path.

classify_files(folder, pattern, labels, ...), tag_files(folder, pattern, labels, max_labels=3, ...)

The batch tools over up to 50 files in one folder matching a glob ("*.md", "**/*.md"); ids are paths relative to the folder. Always local only.

tag, find_sensitive, same and grounded are served by any model whose caps allow them (all models by default). A roster whose models list explicit caps without them keeps working; calling the tool then says no model serves it.

How it behaves

  • Schema first, then verify. Each call asks the backend for schema-constrained JSON, and then checks the reply with jsonschema. Whether a backend actually enforces the schema varies, so a reply that is not valid JSON or does not match the schema is an error, not a guess.

  • No silent fallback. If the backend is down you get MINION_DOWN, also when it drops the connection mid-request (reset, or disconnected without a response); a timeout is MINION_BACKEND_ERROR. Nothing is retried on another model. The one exception is a soft failure (a rate limit, or a managed model refused by the quiet gate with MINION_BUSY) on a route with a configured fallback (see cloud minions).

  • Untrusted input. Your text is wrapped in <data> tags, a closing </data> inside it is neutralised, and the system prompt tells the model never to follow instructions found in the data. This reduces prompt-injection risk. It is not a guarantee, which is why the output schema is checked in code.

  • Stable error codes: MINION_DOWN, MINION_BACKEND_ERROR, MINION_SCHEMA, MINION_BAD_INPUT, MINION_BAD_CONFIG, (v0.2) MINION_NO_QUORUM, (v0.3) MINION_PRIVACY and MINION_RATE_LIMITED, (summarize_url) MINION_REFUSED_ADDRESS, (managed models) MINION_BUSY and MINION_LOAD_FAILED, (extract and summarize) MINION_EMPTY and MINION_NOT_IN_SOURCE, and (summarize) MINION_INVENTED (a kind of MINION_NOT_IN_SOURCE) and MINION_TOO_LONG. These four are kinds of schema failure.

Install and run

Requires Python 3.10+ and uv. You also need Ollama or LM Studio running with a model loaded.

git clone https://github.com/MartinTheGuitarMan/ai-henchmen
cd ai-henchmen
uv sync
MINION_BACKEND=ollama MINION_MODEL=<your-model> uv run ai-henchmen

The old command name mcp-minions still works, so existing client entries need no change. Example MCP client entry (the exact file and format depend on your client):

{
  "mcpServers": {
    "minions": {
      "command": "uv",
      "args": ["--directory", "/path/to/ai-henchmen", "run", "ai-henchmen"],
      "env": { "MINION_BACKEND": "ollama", "MINION_MODEL": "<your-model>" }
    }
  }
}

Configuration (environment variables)

Variable

Default

Meaning

MINION_MODEL

required

Model name as the backend knows it

MINION_BACKEND

lmstudio

lmstudio or ollama

MINION_BASE_URL

http://127.0.0.1:1234/v1 (LM Studio), http://127.0.0.1:11434/v1 (Ollama)

OpenAI-compatible root, ending in /v1

MINION_TIMEOUT

120

Seconds to wait for the model

MINION_MAX_FILE_BYTES

1000000

Size cap for the *_file tools

MINION_OLLAMA_NATIVE

unset

Set to 1 to use Ollama's native /api/chat format instead of response_format

Config file

Instead of many MINION_* variables, point MINION_CONFIG at one TOML (or JSON) file with an optional [machine] table of flags and thresholds (lifecycle, min_free_pct, large_gb, quiet_windows, allow_battery, model_dir, state_dir, log, meta, ...), your [models.*] and [routes.*]. Real environment variables always win over the file, an unknown or mistyped setting is MINION_BAD_CONFIG, and keys never go in the file (cloud models name their key variable). With only a [machine] table the single-model setup from MINION_MODEL still applies. MINION_ROSTER keeps working. See examples/minions.example.toml; keep your own copy outside the repo.

uv run ai-henchmen init writes a commented starter file (default ~/.config/minions/minions.toml; --path, --backend lmstudio|ollama, --model <name>). It never overwrites an existing file without --force, does not contact any runtime and writes no keys.

Roster, quorum and judge (v0.2)

Set MINION_ROSTER to inline JSON or a path to a JSON file to use several models. Without it, the single-model setup above applies unchanged.

{
  "models": {
    "g1":  {"backend": "lmstudio", "model": "google/gemma-3-1b",   "kind": "fast"},
    "e4b": {"backend": "lmstudio", "model": "google/gemma-4-e4b",  "kind": "fast"},
    "q9":  {"backend": "ollama",   "model": "qwen3.5:9b", "kind": "reasoning", "max_tokens": 4096}
  },
  "routes": {
    "classify":  {"fast": ["g1", "e4b"], "quorum": 2, "judge": "q9"},
    "extract":   {"fast": ["e4b"], "judge": "q9"},
    "summarize": {"fast": ["g1"]}
  }
}
  • kind: fast models use constrained decoding (schema requested, then validated by us). kind: reasoning models are called without constrained decoding: they think freely, the think block is stripped, the last JSON object is extracted and validated against the schema by us, with one retry that feeds the error back.

  • Quorum (classify and grounded). Every fast model in the route votes; a label (or verdict) wins with at least quorum votes (default: majority) and a strict lead.

  • Judge. Runs only on disagreement or schema failure, never otherwise. It verifies the proposals (confirm one, or correct it) instead of redoing the task; on a schema failure it repairs the fast model's invalid output. With no judge configured, disagreement raises MINION_NO_QUORUM and a schema failure raises MINION_SCHEMA.

  • Position-bias mitigation. Small models are sensitive to label order (in testing, a 1B model labelled "I love this" as negative with [positive, negative] and correctly with the order flipped). With several fast models each gets the labels in a different rotation. With a single fast model and a judge, the model is asked twice, original and reversed order, and a mismatch counts as a disagreement. Control it per route with "debias": true/false; it never applies outside classify, and a single model with no judge stays a single call.

  • Hard failures are never routed around. MINION_DOWN and backend errors from any model propagate; the judge is not used to mask them.

  • Models that share a server are called one after another, because LM Studio swaps models in and out and concurrent requests to two models on one server evict each other. Different servers run in parallel.

  • Set MINION_META=1 to add a _minion key (votes, judge used) to results.

Cloud minions (v0.3)

A roster can include hosted models next to local ones, for public text only. The default is the safe one: every call is sensitive=true, so cloud models are never used unless the caller passes sensitive=false. See examples/roster.cloud.example.json. The repo ships no real roster and no keys: your own roster and *.env key files live outside the repo.

"groq": {"backend": "openai-compat", "base_url": "https://api.groq.com/openai/v1", "model": "<model id>",
         "cloud": true, "key_env": "GROQ_API_KEY", "caps": ["summarize", "classify"],
         "limits": {"rpm": 30}, "constrain": "json_object"}
  • Privacy gate. cloud: true models are removed from the route when a call is sensitive, which is the default. If no local fast model is left, the call is refused with MINION_PRIVACY; it is never answered elsewhere. The *_file tools are always sensitive. A model on a public host must say "cloud": true (otherwise the roster is rejected), so a cloud endpoint cannot be listed as local by mistake.

  • Keys come from the environment variable named in key_env and are never stored, logged or shown. A missing key is MINION_BAD_CONFIG.

  • caps lists the tools a model may serve (default all). With no explicit fast list, a route picks the first fast model that has the capability for that tool, not a model by name.

  • Rate limits. limits.rpm is counted by this server per minute; at the cap the call is refused without being sent. A provider 429 is retried twice with backoff (honouring Retry-After up to 10 s), then reported as MINION_RATE_LIMITED. Provider quotas are not queried.

  • Failover is for soft failures only. A route may set "fallback": ["other-fast-model"], tried in order on MINION_RATE_LIMITED and on MINION_BUSY (a managed model refused by the quiet gate: nothing was loaded or sent) and on nothing else: MINION_DOWN, MINION_BACKEND_ERROR, MINION_LOAD_FAILED (a load that was tried and failed) and schema failures still propagate. If every model is skipped, the last such error is raised; there is never a canned answer. In a quorum (classify, grounded) a busy voter is skipped exactly like a rate-limited one (its own fallback is tried first); if no vote is left the error is raised, and the judge is not asked to verify an empty vote. Skipped models are logged (busy, rate_limited, failover) and counted by minion_report. Sensitive calls never fail over to a cloud model. Fallbacks that already vote in a quorum are skipped.

  • constrain sets how the schema is requested from a provider: json_schema (default), json_object (provider only offers JSON mode) or none. Replies are validated against the schema either way.

  • minion_status lists models, whether each is cloud, its capabilities, whether its key is set (never the key), requests this server sent in the last minute, and the provider's own x-ratelimit-* headers from its latest reply, exactly as sent (Groq and Mistral send them; they are not interpreted, so the names and units differ per provider). Quota shows only after the minion has been called in the same server process.

  • Free tiers are not private. Providers may use prompts for training, and limits change often, so keep them in the roster and check the provider's console.

Open questions: ask and the hippo test

ask(question, sensitive=true) returns {answer, confidence} for a general question. Small models invent facts when a question has a false premise, so only models marked "knowledge": true in the roster may serve it, and you should set that flag only after the model passes a false-premise test (for example: "summarize the 2019 paper that first described a species that does not exist" must get "I cannot confirm that", not an invented paper). Without such a model the tool answers MINION_BAD_INPUT. The privacy gate applies as for every tool: a cloud model gets the question only with sensitive=false. Do not use ask for anything that must be exactly right.

Machine status (read-only, opt-in)

Set MINION_LIFECYCLE=1 and minion_status gains a lifecycle block: which models are resident and installed in LM Studio (lms ps/ls --json) and Ollama (/api/ps, /api/tags), free memory, memory pressure, power, an optional hold marker, quiet windows, free disk on the model volume, and whether a model load would be allowed right now. It only reads: nothing is loaded, unloaded, downloaded or deleted, and only a fixed list of read-only commands is ever run. A probe that cannot be read blocks the load verdict. Design and next steps: docs/dynamic-models.md.

Variable

Default

Meaning

MINION_LIFECYCLE

unset

1 adds the lifecycle block to minion_status

MINION_MIN_FREE_PCT

45

Free memory needed before a load would be allowed

MINION_LARGE_GB

6

A resident model above this counts as large (one at a time)

MINION_HOLD_FILE / MINION_HOLD

~/.minions/hold / unset

A present file (optional reason line, optional ISO expiry line) or MINION_HOLD=1 blocks loads

MINION_QUIET_WINDOWS

unset

Comma-separated local-time windows such as 23:00-06:00

MINION_ALLOW_BATTERY

unset

1 allows loads on battery

MINION_MODEL_DIR

unset

Folder on the model volume, for the free-disk line

Managed models: auto-load, minion_load and minion_unload

A local LM Studio or Ollama roster entry with "manage": true (optional context default 4096, ttl_s default 600, mem_gb estimate) can be loaded and unloaded by minions. The runtime unloads it itself after ttl_s idle seconds.

  • minion_load(name) loads it only if the quiet gate passes (see the machine status variables above): enough free memory, no memory pressure, on AC power, no hold marker or quiet window, and no other large model. Otherwise it raises MINION_BUSY with the reasons and changes nothing. If another large model that minions itself loaded is resident, that one is unloaded first. A runtime failure is MINION_LOAD_FAILED.

  • Auto-load before a call. When a route (fast model, fallback, judge, or the grounded quote model) reaches a managed model, minions first checks that it is resident (lms ps --json, or Ollama's /api/ps). If it is, the call goes ahead and the gate is not consulted. If not, the same gate and load as minion_load run first, with the entry's context, ttl_s and mem_gb. A refusal is MINION_BUSY and the route falls back to its next model (fallback); a failed load is MINION_LOAD_FAILED and propagates. The request is never sent to a managed model that is not resident. One lock per model, so concurrent calls load it once, and a model answering a call is never unloaded to make room for another (that load is refused with MINION_BUSY). The load has its own time budget (up to 600 s for the lms load command); the model's timeout covers only the request, which starts once the model is resident.

  • LM Studio just-in-time loading. With "Just in Time model loading" on (the default in recent versions), LM Studio loads a model by itself when a request names one that is not loaded, at its own context and TTL, past the quiet gate. Auto-load avoids this by checking residency before every call to a managed model. One small window remains: if the model's TTL ends between the check and the request, LM Studio JIT-loads it. The window is the time between the check and the request (a fraction of a second); to close it entirely, turn JIT loading off in LM Studio's server settings. Ollama also loads on request; the same check applies.

  • minion_unload(name) only unloads a model that minions loaded itself (recorded in MINION_STATE_DIR, default ~/.minions). A model loaded by anything else is never unloaded from here.

  • Unmanaged models are never touched. Loads, unloads and refusals are logged as events in MINION_LOG (no text) and counted in minion_report under lifecycle.

  • Nothing downloads or deletes. See docs/dynamic-models.md.

Decision models (System One)

A System One model answers a typed question about a text with a probability instead of generating text. A roster entry like TypeSafe's Jev ("backend": "systemone", "kind": "decision", optionally "cloud": true with key_env) can serve classify and the verdict of grounded. It works in two ways:

  • As a voter: list it in a classify route's fast models; its label is one vote.

  • As a confident first line: "shortcut": {"model": "<name>", "min_confidence": 0.9} on the classify (or grounded) route. If the decision model's confidence reaches the threshold, its label is returned alone and nothing else is called. Below it (or when it is rate limited or fails the schema check), the normal voters and judge run. With MINION_META=1 the result shows whether the shortcut escalated.

A cloud decision model is never used for sensitive calls, as for every cloud model. Confidence values are model-specific, so tune min_confidence on your own labelled data rather than copying a number. A threshold set too high means the shortcut never answers alone and only adds a call. To see where it stands: the usage log records the shortcut's confidence and threshold on every call, minion_report shows their spread under shortcut_confidence (per tool and model: min, p10, p50, p90, max, how many calls reached the threshold, how many it answered alone, and how many had no confidence: before_logging for log lines written before confidence was logged, model_gave_none for replies without a usable number). The local jev-style serve does return a confidence and per-label probabilities on /v1/systemone; a null in an old report means those old lines, not the server. On a dozen invented customer messages its confidence ranged from 0.44 to 0.97 (median about 0.92) and only 3 of 12 reached 0.95, so with that threshold the shortcut answers alone only on the clearest texts. scripts/eval.py --confidence prints confidence-versus-correct pairs on labelled items with a table of how many calls each threshold would answer alone and how many of those are right:

MINION_ROSTER=my-roster.json uv run python scripts/eval.py --confidence --data evals/dataset_large.json --split dev

Pick the threshold on dev, then check it on test. The eval asks the decision model directly with the classify task text; the injection defence that runs before a live shortcut is not applied. Request shape (a choice question with criteria over the labels) follows the public Jev examples; the label is checked in code like every other model's output.

Quick accuracy check: scripts/quick_eval.py

A small fixed set of invented cases (5 to 9 per tool) for classify, tag, extract, summarize, grounded, ask and find_sensitive, sent through the real routes the way a caller would and scored in code. It is meant to run weekly and take a few minutes (the full bench takes hours). ask includes made-up animals, papers and places: any answer that does not refuse is an invented answer and is printed on its own INVENTED line. find_sensitive includes planted fake secrets, a fake address and clean texts that must produce nothing.

MINION_ROSTER=my-roster.json MINION_ACCURACY_LOG=accuracy.jsonl uv run python scripts/quick_eval.py
  • One JSON line per run is appended to the file MINION_ACCURACY_LOG names: date, a hash of the roster (models and routes, never key values), per tool the score, latency p50 and p95, which models answered and any errors or skipped cases, plus the invented count. Without the variable the summary is printed and nothing is written.

  • Calls are sensitive=true (local models only) unless you pass --public. A rate limit, a refusal by the quiet gate or privacy gate, or a tool the roster does not serve counts as skipped, not wrong; any other error counts as a wrong answer. A managed model the quiet gate refuses is failed over as usual, so the models field shows who really answered.

  • The run's own call log goes to quick_eval_calls.jsonl next to the accuracy log, so minion_report is not skewed. Managed models the run loaded are unloaded at the end (--no-unload to keep them). --budget-s (default 540) stops starting new cases; the line then has "partial": true.

  • With about 8 cases per tool one case is 12 points: look for changes between runs, not for small differences.

Grounding check: grounded

grounded(claim, source) checks one claim against one source text: a fact proposed from a note, or a sentence of a summary against the text it summarizes (it catches invented numbers, names and dates). It returns {"verdict": "supported" | "contradicted" | "unclear", "quote": "..."}, plus confidence (0 to 1) when a decision-model shortcut gave the verdict.

  • The quote is checked in code. A supported or contradicted verdict stands only if its quote occurs in the source (whitespace and letter case normalised, at least 3 characters). A quote taken from the claim, an invented passage or an empty quote downgrades the answer to unclear, with a reason. unclear always has an empty quote. A verdict outside the three values is MINION_SCHEMA, as everywhere.

  • Routing like classify. The route key is grounded and the caps name is grounded. Several fast models vote on the verdict with a quorum; the winning verdict keeps the quote of a model that voted for it. On disagreement the judge verifies the proposals and gives its own quote, which is checked like any other; with no judge, a missed quorum is MINION_NO_QUORUM.

  • Decision models (backend: systemone) answer the verdict as a pick-one question, as a voter or as a shortcut. They give no quote, so when they decide alone, the first generative fast model on the route is asked only for the quote; with none, the answer is unclear. The injection defence that strips instruction segments runs on classify only for now.

"routes": {"grounded": {"fast": ["g1", "e4b"], "judge": "q9"}}

Value check: extract

A reply can match the schema and still say nothing: with vague field names a small model may return {"order": "", "date": "", "city": "", "boxes": 0} for a text that states all four. So after the schema check, extract (and extract_file) checks the values against the source text in code:

  • Required strings must not be empty. An empty or whitespace-only value is MINION_EMPTY.

  • Values must occur in the source. A string must appear as whole words after folding case, accents, whitespace and punctuation ("MALMO" matches Malmö,; "Lund" does not match Lundby). A date also matches the same date in another simple form (2026-11-03, 3 November 2026, Nov 3, 2026, 03/11/2026; English month names, d/m and m/d both accepted). A number must appear as digits in the source, with or without grouping (1,250.50, 1.250,50, 12 400), or as an English number word up to ninety-nine. A computed value (a sum, a converted unit) is therefore not found. A required value that is not found is MINION_NOT_IN_SOURCE.

  • A required number that is 0 passes when the source has a 0 in it; otherwise it is treated as a placeholder and is MINION_EMPTY. If "none" is a real answer for your data, make the field optional or nullable.

  • Optional fields are flagged, not failed. Missing or empty is fine (not stated). A value not found in the source is kept and its field is listed in "_unverified": [...] on the result.

  • Not checked: booleans, null, fields with enum or const, and nested objects. Only top-level properties are checked, plus the strings and numbers inside a top-level array. For a field the model should infer rather than copy (a category, a mood), use an enum or make it optional.

Both codes are MINION_SCHEMA subclasses, so they route like any schema failure: with a judge on the route, the judge repairs the answer and its repair must pass the same check (one retry with the reason fed back); without one, the error is returned. The privacy gate, quorum and failover rules are unchanged. The usage log records the kinds a judge repaired as check_failures, and minion_report counts them under check_failures_repaired; unrepaired ones show up in errors.

Content check: summarize

A summary can be well-formed and still invent a figure, shift a date or add a name. So after the schema check, summarize (and summarize_file) checks the summary against the source text in code, with the same normalisation as extract's value check:

  • Numbers must occur in the source: as written, with or without grouping (1,250,000 for 1250000), with either decimal mark, or scaled by a word the source or summary spells out (1.25 million for 1,250,000). A plain decimal must match as a whole (1.3 is not found from a 1 and a 3); a token of three or more parts (1.2.3, 3.11.2026) also passes when every part occurs. A rounded, summed or converted number fails (about 45% for 44.6%).

  • Dates in a simple form (2026-11-03, 3 November 2026, Nov 3, 2026, 03/11/2026) pass when the source has the same date in any of those forms. Otherwise their numbers and month name are checked one by one.

  • Capitalised names and terms (a word starting with a capital) must occur in the source as a whole word after folding case and accents, without a plural or possessive ending (Okafor's, Okafors), or share all but their last three letters with a source word, for words over four letters (Swedish for Sweden). Letters inside a code (4B) are not words; its digits are checked as a number.

  • Not checked, to avoid false failures: the first word of a sentence, a list item or a clause after a colon (unless it is all capitals or has a capital inside it, like NASA or McKinley), common words such as The, Overall or I, lower-case words, and number words (three).

  • Length: at most max_words plus 10 % (at least 2 words), counting whitespace-separated words, since models count loosely. A longer summary is MINION_TOO_LONG; an empty one is MINION_EMPTY.

A failing summary is MINION_INVENTED, listing the tokens not found. All of these are MINION_SCHEMA subclasses, so they route like extract's checks: with a judge on the route, the judge repairs the summary and its repair must pass the same check (one retry); without one, the error is returned. The usage log records the kinds a judge repaired as check_failures.

One repair retry. For MINION_INVENTED, summarize, summarize_file and summarize_url run the route once more with a task that names the invented terms and asks for a summary without them. The new reply goes through the same check and, if it fails again, that error is raised. A call saved this way shows MINION_INVENTED under check_failures_repaired in minion_report.

Limits. Each token is checked alone, so a wrong pairing of facts that each occur (March 2026 when the source has March and 2026 separately, or names swapped between roles) is not caught, and neither is a false statement in lower-case words (rose for fell). Use grounded per sentence for that. Sentence-initial names are skipped, so an invented name that opens a sentence passes unless it is all capitals or appears again later. English month names and number words only.

Fetching a page: summarize_url

summarize_url(url, max_words=100, sensitive=true) fetches one web page and runs it through summarize (same route, same content check against the fetched text), so a public page can be summarised without the calling model reading it. The page text is public, but the call stays sensitive=true by default (local models only); sensitive=false allows cloud models. The page is untrusted data, wrapped in <data> like any input. Only the first 8000 characters of the text are summarised; truncated says when the page was longer.

Because this lets a caller make the server fetch a URL, the guards are enforced in code (minions/fetch.py) and tested:

  • http and https only, default ports only (80 and 443), no credentials in the URL.

  • Public addresses only. The host is resolved and every address must be public unicast: loopback, private, link-local, multicast, reserved and cloud-metadata addresses are refused, for IPv4 and IPv6, including IPv4-mapped, 6to4, NAT64 and Teredo forms. localhost, single-label names and .local, .internal and similar names are refused before any lookup. A refusal is MINION_REFUSED_ADDRESS, and nothing is fetched.

  • Checked again after every redirect (at most 3), and the checked address is pinned: the connection goes to that IP (with the original host name for the Host header and TLS), so DNS cannot change between check and fetch.

  • Capped: 15 seconds in total, 5 MB of raw download (streamed, stopped at the cap), text/html and text/plain only, no compression. Text is extracted with the standard library (scripts and styles dropped).

  • Nothing sent but a fixed minimal header set: no cookies, no auth headers, no secrets.

Other failures (a bad URL, a timeout, too large, not text, HTTP error) are MINION_BAD_INPUT with the reason.

Diff scan: scan_diff

scan_diff(folder, range) screens what a push would publish. folder is a git work tree; range is BASE..HEAD (for example origin/main..HEAD) or a single base ref, meaning BASE..HEAD. It scans:

  • the added lines of git diff from the merge base of the two refs to the head (removed and context lines are not scanned), and

  • the commit messages of BASE..HEAD.

The text is split into chunks of about chunk_chars characters (default 3,000, 500 to 8,000; a small model's list of spans is more often valid JSON on short chunks), and find_sensitive runs on each, local models only. It returns {"chunks": [...], "ok", "failed", "found", "complete", "skipped", "commits", "files", "base", "head", "merge_base"}. Each chunk has an id (diff-1, ..., msg-1, ...), from (the files or commits in it), and spans with text, kind, by (rule or model) and where (path:line or commit <id>).

  • One bad chunk does not fail the scan. A chunk whose model answer is not valid JSON, fails the schema or names a span that is not in the text gets error: {code, message}; its pattern hits (emails, keys, phone numbers, long ids, which need no model) are still reported in spans. The other chunks still run. After MINION_DOWN, MINION_RATE_LIMITED, MINION_PRIVACY or MINION_BAD_CONFIG the remaining chunks are marked not attempted instead of hammering the same failure.

  • complete is true only when every chunk was scanned and no file was skipped. Read failed and skipped before trusting a clean result.

  • Binary and non-UTF-8 files are not decoded or scanned; they are listed in skipped with the reason.

  • Size caps. The raw diff is capped at 4 MB, the text to scan at 300,000 characters and the range at 500 commits. A bigger range is refused with MINION_BAD_INPUT, never silently cut; scan it in parts.

  • Refs are checked before git sees them. Each side of the range must be letters, digits and ._/~^@{}+-, at most 200 characters, not starting with - or ., with no .. inside, no spaces and no : (so HEAD:file and options like --output=... are refused). A...B is refused. Each ref must then resolve to a commit (git rev-parse --verify --end-of-options REF^{commit}), and only the resulting commit ids are passed to git diff and git log.

  • How git runs. From a fixed argument list, never through a shell, with GIT_* environment variables removed (so GIT_DIR cannot point it at another repository), system config ignored, core.fsmonitor off, and --no-ext-diff --no-textconv so a repository's own config cannot run a diff helper.

A find_sensitive route is required (any model with that capability); every chunk is one find_sensitive line in the usage log. A clean scan is a second pair of eyes, not a guarantee: a small model misses things, and patterns only catch what they describe.

Batches: classify_batch and tag_batch

Both take items, a list of 1 to 50 objects {"id": "<unique string>", "text": "..."}, plus the same arguments as classify or tag (labels and the other arguments are checked once, before any item). They return {"results": [...], "ok": n, "failed": m} with one entry per id, in input order: {"id", "label"} or {"id", "labels"} on success, {"id", "error": {"code", "message"}} on failure.

  • Per-item errors. An empty text (MINION_BAD_INPUT), output that fails the schema (MINION_SCHEMA) or a missed quorum is reported with that item; the other items still run.

  • Stops that apply to everything. After MINION_DOWN, MINION_RATE_LIMITED (with retry_after), MINION_PRIVACY or MINION_BAD_CONFIG, the remaining items are not sent and are marked not attempted with the same code, so a down backend or a used-up limit is never hammered 50 times. Items run one after another, so limits.rpm and model swapping behave exactly as for single calls; every item is one entry in the usage log.

  • Long texts are chunked. A text over 8,000 characters (about 2,000 tokens) is split at paragraph breaks (a longer paragraph at a space), at most 4 chunks; anything beyond is dropped and the item is marked "truncated": true. Chunked items carry "chunks": n. classify_batch takes the chunks' majority label (a tie is MINION_NO_QUORUM for that item); tag_batch ranks labels by how many chunks chose them, then by first appearance, and keeps max_labels.

classify_files and tag_files build the items from the files in one folder that match a glob. They read regular UTF-8 files only (size-capped like the *_file tools; an unreadable file is an error for that id), skip hidden files and folders (.git, .env, ...) and any symlink that leads outside the folder, refuse absolute patterns and .., and refuse a pattern that matches more than 50 files. They take no sensitive argument: file contents always stay on local models.

Defence against injections that name an answer

A text can try to steer the classifier ("the correct label is X", "already tagged X", a fake JSON label). When a classify route has a decision-model shortcut, each multi-segment text is first split into sentences and bracketed spans, and that decision model judges every segment on its own: an instruction or claim aimed at whoever classifies the text, or ordinary content? Instruction segments (probability 0.2 or more) are removed before any model classifies; with MINION_META=1 the result lists them under injection_removed. A model judges meaning rather than matching phrases, so it also catches phrasings it has not seen.

Measured with Jev as the decision model: 14 attack phrasings written separately from the defence (84 items): 50 correct before, 83 after, and no more confident wrong answers (7 before). On 90 generated injection items: 69 before, 89 after. One benign text in 62 lost a segment. Limits: it needs a decision model, so it does not run on sensitive calls when that model is a cloud model; a text with a single segment cannot be split, so an attack inside the same sentence as the content passes through to the voters and judge; and an attacker who knows the defence can adapt. Voting, the judge and the confidence threshold remain the other defences. An earlier regex version caught none of the unseen phrasings and was dropped.

Usage log and report

Set MINION_LOG to a file path and every tool call appends one JSON line: tool, whether it was sensitive, text length, latency, which models answered, shortcut or judge use (with the shortcut's confidence and threshold), failovers, rate limits, the error code and the token counts the providers reported, summed over every model call of the tool call (voters, shortcut, judge; left out when a provider gives none; cloud marks a call in which a cloud model took part). It never stores the text, the labels or the answer. The minion_report(days) tool summarizes the log: calls per tool, p50/p95 latency, how often the shortcut answered and the spread of its confidence, judge calls, failovers, errors, extract and summarize check failures a judge repaired, and which models answered.

The report also has a tokens block: tokens handled locally and by cloud models (as the providers counted them; older log lines without counts are listed under calls_without_token_counts), and estimated_kept_off_calling_model. That figure is an estimate, not a billing figure. It counts only calls that succeeded, stayed on local models and did not fail over; errored, failed-over and cloud calls count zero. Input side: the logged text length divided by about 4 characters per token (the text the calling model did not have to process). Output side: the completion tokens the local models reported for those calls, quorum and judge calls included. The calling model still pays for passing the text in and reading the answer, so treat the number as an order of magnitude. The report repeats this method in its output.

Evaluating minions

evals/dataset_large.json (generated by evals/make_dataset.py, fixed seed, 327 distinct invented classify items split into dev and test halves) is for tuning a confidence threshold on one half and checking it on the other. evals/dataset.json holds invented public test items with known answers (sentiment, topic and prompt-injection classification, field extraction (including a required field the text does not state, where the only right outcome is MINION_EMPTY or MINION_NOT_IN_SOURCE), summaries checked for invented numbers, grounding checks of claims against a source (with invented numbers and a quote that must come from the source), short notes for tag_batch (labels that must and must not be chosen), and general questions with and without a false premise). scripts/eval.py runs each roster model alone, and optionally the whole classify route as a quorum, then prints a table of correct answers, schema failures, rate limits and median latency. Rate limits are waited out and counted, never scored as wrong answers.

MINION_ROSTER=my-roster.json uv run python scripts/eval.py --quorum --out results.json

Scoring is deliberately simple (exact label, field containment, a refusal pattern for false-premise questions), so read the raw outputs saved with --out before you trust a single miss. Use a small --max-calls name=N for models on free tiers with daily caps.

Security note

The *_file tools read any UTF-8 text file the caller names (up to the size cap), and classify_files and tag_files read up to 50 such files from any folder the caller names. Whoever can call this server can therefore read files your user account can read. scan_diff widens this read surface further: it reads the added lines and commit messages of any git work tree your account can read, through git (see Diff scan for the checks on refs and how git is run). Only connect clients you trust, and leave the file tools and scan_diff out of any setup where that is a problem.

Tests

uv run pytest

The unit tests use a fake backend. To try a real model, scripts/smoke.py runs every tool against a live backend and repeatedly probes whether the schema constraint holds, including hostile and prose-bait inputs:

uv run python scripts/smoke.py ollama <your-model> --repeat 5

Status

Version 0.2. Early and small; expect changes.

License

MIT, see LICENSE.

Agents

An agent picks its own steps, so nothing it does is checked step by step the way a minion's output is. What keeps it safe is where it works and what it may touch.

gemini_agent(task, repo, role="default", base_ref="HEAD", test_command=None)

Runs Gemini CLI headless as a coding agent.

  • Throwaway clone. Each run clones the repo at base_ref (committed work only) into ~/.cache/minions/agent-runs/<run_id>/work and removes the remote. The repo itself is never touched.

  • No blanket approval. No --yolo. The default role runs with --approval-mode auto_edit (edits, in the clone only); codereviewer runs with default (no edits). --sandbox is on.

  • Policy file (agents/policies/gemini-agent.toml): pytest, ruff check and read-only git may run. Git remote commands, curl/wget/ssh, package installs, web_fetch and google_web_search are denied. Anything else needs approval, and headless mode treats that as a refusal.

  • Result: changes.patch, a --stat, the agent's own <SUMMARY>, token stats and a status: ok, refused, cli_error, timeout, rate_limited or turn_limit. You apply the patch yourself: git -C <repo> apply <patch_path>.

  • Tests are not the agent's word. With test_command, the server runs it in the clone after the agent is done and reports the exit code.

  • Privacy. Gemini CLI is a cloud model, and its free tier may use prompts for training. A run is refused unless the privacy map labels the repo low (no map means no repo), and unless the base commit is already on a remote-tracking branch: unpushed work is not yet public. A repo holding only invented data can be listed in unpushed_ok_repos to skip the second check.

Setup: install the CLI (npm install -g @google/gemini-cli) and run gemini once to sign in. Optional settings go in ~/.config/minions/agents.json (or MINION_AGENTS_CONFIG):

{
  "gemini": {"command": "gemini", "model": null, "timeout_s": 900, "sandbox": true, "max_response_chars": 20000},
  "unpushed_ok_repos": []
}

The same runner is on the command line: uv run henchmen-agent gemini --repo <path> --test "python -m pytest -q" "<task>".

Status: trial. It is not routed by route_task until it passes the coding bench as an agent.

The runner pattern is adapted from clink in PAL MCP Server (Apache-2.0); see NOTICE.

Available Tools

6 tools
classifyC

Classify text into exactly one of the given labels using a local model.

NameRequiredDescriptionDefault
hintNo
textYes
labelsYes

TDQS

C2.9/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations exist, so the description carries the full burden. It does disclose two useful traits — processing is done by a 'local model' (no external API) and output is constrained to a single label — but says nothing about failure modes when text matches no label, determinism, or latency.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

A single tight sentence with the constraint front-loaded and no filler. It is appropriately sized, though the brevity is partly the source of the coverage gaps rather than pure economy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With no annotations, no output schema, and 0% parameter description coverage, the description should carry much more. It omits what the return value looks like (label only, label plus score?), how 'hint' behaves, and any error behavior — real gaps for a 3-parameter tool.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must compensate. It clarifies that 'labels' is a candidate set from which exactly one is chosen, but the 'hint' parameter is never mentioned anywhere, leaving a required piece of the API undocumented.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb (classify) and resource (text) plus the key constraint 'exactly one of the given labels'. The word 'text' implicitly separates it from the sibling classify_file, though it never names that alternative explicitly.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No guidance on when to choose this over classify_file, extract, or summarize. The only routing signal is the implicit 'text' vs file distinction, which the agent must infer from sibling names alone.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

classify_fileB

Like classify, reading UTF-8 text from a local file (size-capped).

NameRequiredDescriptionDefault
hintNo
pathYes
labelsYes

TDQS

B3/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full behavioral burden, and it does disclose two real traits: input must be UTF-8 text and the read is size-capped. It does not state the actual size limit, what happens on non-UTF-8 or oversized files, or any error/permission behavior, leaving meaningful gaps for a file-reading operation.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

It is a single tight sentence with no waste and the key constraint (file input) is front-loaded. Brevity here reflects under-specification rather than disciplined conciseness, since essential details are simply absent.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a 3-parameter tool with 0% schema coverage, no annotations, and no output schema, the description is too thin. It omits parameter meaning, the size cap value, error behavior, and how results relate to the sibling 'classify' output.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% across three parameters, so the description must compensate, and it largely does not. It hints at 'path' (local file) but says nothing about 'labels' (required) or 'hint', leaving the agent to guess at their meaning from names alone.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a clear purpose by anchoring to the sibling 'classify' and adding the file-based variant: it classifies UTF-8 text read from a local file. This distinguishes it from the plain 'classify' tool, though the verb itself is only implied via the name rather than stated.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Saying 'Like classify, reading UTF-8 text from a local file' implicitly tells the agent to use this when input is a file rather than inline text. However, it gives no explicit when-to-use/when-not guidance and never mentions the other file siblings (extract_file, summarize_file) as alternatives.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

extractC

Extract fields from text into an object matching a JSON Schema (type: object).

NameRequiredDescriptionDefault
hintNo
textYes
schemaYes

TDQS

C2.7/5.0
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries the full behavioral burden, and it discloses almost nothing: no indication of whether extraction is model-driven, how it behaves on non-conforming or missing fields, whether it errors or returns partial results, or any cost/latency characteristics.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

A single front-loaded sentence with no filler; the output contract is stated up front. It is efficient, though the brevity comes at the cost of the missing detail noted in other dimensions.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With no annotations, no output schema, 0% schema coverage, and a nested schema parameter, the definition is too thin. It should at minimum explain the 'hint' parameter, the expected JSON Schema shape, and failure behavior when extraction cannot satisfy the schema.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% across 3 parameters. The description echoes 'text' and 'schema' and adds one useful constraint (the schema must be type: object), but the third parameter 'hint' is entirely undocumented in both the schema and the description.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb (extract) and resource (fields from text) plus the output contract (an object matching a JSON Schema). The phrase 'from text' implicitly separates it from extract_file, but it never names or contrasts with the sibling explicitly.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No when-to-use guidance, no prerequisites, and no mention of alternatives such as extract_file for file inputs or classify/summarize for other tasks. The only routing signal is the incidental 'from text' phrasing.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

extract_fileB

Like extract, reading UTF-8 text from a local file (size-capped).

NameRequiredDescriptionDefault
hintNo
pathYes
schemaYes

TDQS

B3.1/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full burden, and it does disclose two real traits: input must be UTF-8 decodable text and file size is capped. It says nothing about failure behavior on non-UTF-8 or oversized files, filesystem permission requirements, or what the extraction produces.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

A single compact sentence with the source constraint front-loaded and no filler. It is efficient, though the brevity borders on under-specification rather than true conciseness.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

No annotations, no output schema, and 0% parameter coverage for a tool with a nested free-form 'schema' argument. The description should be compensating for that structural silence but instead offers only one clause, leaving an agent without enough to invoke it confidently beyond the happy path.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% across three parameters (path, schema, hint), one of which is a nested free-form object, and the description adds no parameter meaning at all. An agent gets no guidance on what 'hint' does or what shape the 'schema' object should take, which is the main ambiguity for this tool.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description names the operation (extract) and the source (a local file), and its phrasing 'Like extract ... from a local file' positions it as the file-input counterpart of the sibling extract tool. It is clearly distinguishable from classify_file/summarize_file by the verb, though it never states what is extracted (e.g. structured data per the schema).

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

'Like extract, reading UTF-8 text from a local file' implies usage (choose this when the input is a file rather than inline text), but there is no explicit when-to-use statement, no when-not, and no named alternative. Usage is left to inference from the sibling reference.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

summarizeC

Summarize text in at most max_words words.

NameRequiredDescriptionDefault
textYes
max_wordsNo

TDQS

C2.7/5.0
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full behavioral burden and discloses almost nothing: no indication of input size limits, truncation behavior, whether the summary is extractive or abstractive, or what the return looks like. It is at least non-misleading, but it leaves the agent guessing on every behavioral question.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

One short, front-loaded sentence with no filler or repetition. It is efficiently sized, though the brevity comes at the cost of substance rather than being tight around rich content.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a two-parameter tool with no annotations, no output schema, and no parameter documentation, the description should carry more. It omits input constraints, output shape, and sibling differentiation, so an agent cannot call it confidently in ambiguous cases.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% and the description only restates max_words as a word cap (its default of 100 is already in the schema) while saying nothing about the required text parameter. It adds little beyond what the schema title already conveys.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb (Summarize) and resource (text), and constrains the output length via max_words. It does not, however, distinguish itself from the sibling summarize_file, leaving the agent to infer that this one takes inline text rather than a file.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

There is no statement of when to use this versus classify, extract, or summarize_file, nor any prerequisites or exclusions. The agent gets no routing guidance at all.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

summarize_fileB

Like summarize, reading UTF-8 text from a local file (size-capped).

NameRequiredDescriptionDefault
pathYes
max_wordsNo

TDQS

B3.1/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries the full burden. It usefully discloses three behavioral constraints: input must be UTF-8, input must be a local file, and size is capped. It does not say what happens when the cap is exceeded, whether read permissions are required, or what the response contains.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

A single, front-loaded sentence with no filler; the file-based scope is stated first. It is efficient, though 'size-capped' is too vague to fully earn its clause.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With no annotations, no output schema, and 0% parameter coverage, the description should do more. It omits the max_words parameter, the size cap value, error behavior, and output shape, leaving an agent unable to predict results or failure modes.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% for 2 parameters. The description only obliquely gestures at 'path' (local file) and says nothing at all about 'max_words' (which defaults to 100), so the parameter that controls output length is entirely undocumented in both schema and description.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

Names a specific verb (summarize) and resource (local file), and explicitly anchors itself to the sibling 'summarize' by stating it works 'like' it but reads from a file. An agent can distinguish it from summarize, classify_file, and extract_file without opening schemas, though the distinction is by analogy rather than a direct statement.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

'Like summarize, reading UTF-8 text from a local file' implies the selection rule: use this when the source is a file rather than inline text. However, it never states when not to use it, nor what to do for non-UTF-8, non-text, or oversized files.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 6 tool updatesv0.2.0
    • First observedclassify
    • First observedclassify_file
    • First observedextract
    • First observedextract_file
    • First observedsummarize
    • First observedsummarize_file

TDQS

B3.4/5.0

Scored across 6 tools

Disambiguation5/5

classify, extract, and summarize target three clearly distinct operations (classification, schema-based field extraction, summarization), and each _file variant is explicitly documented as the same operation with a file input source. There is no real ambiguity about which tool performs which action.

Naming Consistency5/5

All names use a consistent snake_case verb pattern, with the _file suffix applied uniformly to denote the file-reading variant of each operation. The taxonomy is predictable and readable.

Tool Count5/5

Six tools for three core text operations plus their file counterparts is tight and well-scoped; every tool earns its place with no redundancy beyond the intended input-source pairing.

Completeness4/5

The surface covers the three primary operations for both raw text and local files, forming a coherent lifecycle. Minor gaps exist (e.g. no batch, URL, or stdin input variants), but agents can work around them by reading content themselves.

Maintenance

ActivityMaintained
ResponsivenessSyncing

Related MCP Connectors

Related MCP Servers