The capability card
One artefact is registration, documentation, routing index and test fixture at once.
Milestone Three hand-written cards pass the validator, a deliberately broken one is rejected with a readable message.
This would be the moment to start building a router. You would point it at descriptions of agents that do not exist yet, and you would have built the hardest part of the system on guesswork.
So the artefact comes first. A capability card is one YAML file per capability, and it does four jobs at once:
- Registration - what exists
- Documentation - what it does, and explicitly what it does not
- Routing index - the examples inside it get embedded
- Test fixture - those same examples are the router's eval cases
Four jobs, one file. That is why the format looks the way it does.
The goal
One artefact is registration, documentation, routing index and test fixture at once. Change the examples and you inevitably change the test cases too - a card cannot quietly go stale without the measurement noticing.
The format
Create registry/web-reader.card.yaml:
id: web-reader
version: 1.0.0
summary: >
Fetches one or more URLs, extracts the text content and answers a concrete
question about it. Returns a summary plus source references.
# --- Routing: positive index ---
examples:
- "whats on the page https://..."
- "read that page for me and tell me whats in it"
- "get the prices off company x's website"
- "the phone number is somewhere on the landing page, find it"
- "summarise that blog post for me"
- "what products does the page list"
- "does it say anything about delivery times"
- "check whether they still have open positions"
- "what does it cost with them according to the website"
- "is there a legal notice with an address on the page"
# --- Routing: veto index, SEPARATE ---
counter_examples:
- "search the web for information about"
- "google who the managing director is"
- "add the contact to the crm"
- "send an email to"
does_not:
- "Does not search the web itself, needs a concrete URL"
- "Does not fill in forms, does not click anything"
- "Does not get around paywalls or logins"
# --- Contract ---
inputs:
required: [urls, question]
returns:
required: [answer, sources, found]
# --- Operations ---
budget:
max_output_tokens: 800
max_tool_calls: 8
max_wall_seconds: 60
tools: [http_get, html_to_text]
side_effects: none # none | writes | irreversible
Four fields deserve an explanation, because otherwise you fill them in wrongly.
examples are user phrasings, not descriptions. This is the most important
rule of the entire format. The reason is spelled out in chapter 03; briefly: the
router compares the user's request against these lines. "i need meier's invoice
again" is semantically far away from "agent for document retrieval from the DMS
with full-text search" and very close to "get me the invoice from again". So
write the examples sloppily: lower case, colloquial, with the elisions real
people make.
counter_examples live in an index of their own. "I do not do web search"
contains the words web search and therefore matches beautifully against
exactly the request it is meant to exclude. In the same index that would be an
own goal. In chapter 04 they act as a veto: binary, not a score penalty.
does_not is not embedded. It is prose, it goes into the agent's system
prompt and into the answer to "can you do this?". Humans read it, the index does
not.
budget is not a recommendation. In chapter 06 the runner enforces it, with
scissors if need be. A card without a budget is a card without a compression
boundary, and then you can skip the whole system.
Two more cards
A router with a single agent always routes correctly. For everything from chapter 03 onwards you need at least three, and they should be adjacent enough that confusion is possible. Create:
registry/crm-writer.card.yaml - writes to the CRM (contacts, notes).
Important: side_effects: writes. Examples such as "add the new contact", "note
on meier that he will call back", "create a company for". Counter-examples: "who
is the contact person at", "find me the phone number of" - that is reading, and
reading is somebody else's job.
registry/docs-finder.card.yaml - searches the internal documentation. Examples:
"how did the process for work again", "where does it say anything about the
holiday policy", "is there a guide for". Counter-examples: "whats on the page
https://...", "google" - the first is the web-reader, the second nobody can do.
Take the ten minutes. From chapter 03 onwards the quality of your router hangs on nothing but these lines.
The validator
A card that is quietly wrong poisons the index. So a validator checks it on load. It is short, because it only enforces the rules people actually break:
# tiny/cards.py
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import yaml
SIDE_EFFECTS = {"none", "writes", "irreversible"}
class CardError(ValueError):
"""A card violates the format. The message names file and field."""
@dataclass
class Budget:
max_output_tokens: int = 800
max_tool_calls: int = 8
max_wall_seconds: int = 60
@dataclass
class Card:
id: str
summary: str
examples: list[str]
counter_examples: list[str]
does_not: list[str]
returns_required: list[str]
tools: list[str]
budget: Budget
side_effects: str = "none"
version: str = "0.0.0"
source: Path | None = field(default=None, repr=False)
def _string_list(data: dict, field_name: str, file: Path, at_least: int = 0) -> list[str]:
value = data.get(field_name, [])
if not isinstance(value, list) or any(not isinstance(x, str) for x in value):
raise CardError(f"{file.name}: '{field_name}' must be a list of strings.")
if len(value) < at_least:
raise CardError(
f"{file.name}: '{field_name}' has {len(value)} entries, "
f"at least {at_least} required."
)
return value
def load_card(file: Path) -> Card:
data = yaml.safe_load(file.read_text(encoding="utf-8")) or {}
for required in ("id", "summary", "returns", "tools"):
if required not in data:
raise CardError(f"{file.name}: required field '{required}' is missing.")
# The rule of thumb from the concept: below 8 positive examples routing gets
# brittle, below 3 counter-examples the veto has no effect.
examples = _string_list(data, "examples", file, at_least=8)
counter = _string_list(data, "counter_examples", file, at_least=3)
overlap = set(examples) & set(counter)
if overlap:
raise CardError(
f"{file.name}: {sorted(overlap)} appears in both indexes. "
"An example cannot be a hit and a veto at the same time."
)
side_effects = data.get("side_effects", "none")
if side_effects not in SIDE_EFFECTS:
raise CardError(
f"{file.name}: side_effects '{side_effects}' unknown, "
f"allowed are {sorted(SIDE_EFFECTS)}."
)
budget = Budget(**(data.get("budget") or {}))
if budget.max_output_tokens > 2000:
raise CardError(
f"{file.name}: max_output_tokens={budget.max_output_tokens}. "
"Above 2000 this is no longer a compression boundary."
)
return Card(
id=str(data["id"]),
summary=str(data["summary"]).strip(),
examples=examples,
counter_examples=counter,
does_not=_string_list(data, "does_not", file),
returns_required=list((data.get("returns") or {}).get("required", [])),
tools=[str(t) for t in data["tools"]],
budget=budget,
side_effects=side_effects,
version=str(data.get("version", "0.0.0")),
source=file,
)
def load_registry(folder: str | Path = "registry") -> dict[str, Card]:
cards: dict[str, Card] = {}
for file in sorted(Path(folder).glob("*.card.yaml")):
card = load_card(file)
if card.id in cards:
raise CardError(f"{file.name}: id '{card.id}' already exists.")
cards[card.id] = card
if not cards:
raise CardError(f"There is not a single card in '{folder}'.")
return cards
if __name__ == "__main__":
import sys
folder = sys.argv[1] if len(sys.argv) > 1 else "registry"
try:
cards = load_registry(folder)
except CardError as error:
print(f"ERROR {error}")
raise SystemExit(1)
for card in cards.values():
print(
f"OK {card.id:<14} v{card.version:<8} "
f"{len(card.examples):>2} examples "
f"{len(card.counter_examples):>2} counter "
f"{len(card.tools)} tools "
f"side_effects={card.side_effects}"
)
Two of those checks are more than formality.
The overlap check catches the mistake everybody makes once: copying the same line into both lists out of convenience. The result would be an agent that vetoes itself, and the hunt for that takes an hour.
The budget ceiling is an opinion, and it sits in the code on purpose: allowing 4,000 output tokens gives you not a compression boundary but a polite phrase. If you disagree, change the number - but change it deliberately.
The milestone
python -m tiny.cards registry/
OK crm-writer v1.0.0 9 examples 4 counter 2 tools side_effects=writes
OK docs-finder v1.0.0 10 examples 3 counter 1 tools side_effects=none
OK web-reader v1.0.0 10 examples 4 counter 2 tools side_effects=none
And now the second half of the milestone: break a card. Delete three examples
from docs-finder, or set max_output_tokens: 5000, or copy a line from
examples into counter_examples. Run it again.
ERROR docs-finder.card.yaml: 'examples' has 7 entries, at least 8 required.
If the message names both the file and the field, this step is done. A validator that only says "invalid" is a validator you switch off after the third card.