Skill graph in 5 minutes
Three defineSkill calls, one skillGraph({ skills, start }) — routing declared as data, checked at build, and drawn as a picture you can read.
An agent with several playbooks needs a front door: which skill does a turn start in? You could bury that decision in prompt prose or in
ifstatements. Declare it instead — as data — and the same declaration routes the turn, survives a build-time check-up, and draws itself.
This page is the whole loop: three skills, one graph, and the picture.
The code
Three defineSkill calls and one skillGraph({ ... }). Start rules are tried top
to bottom; the first that matches the user's message wins.
Feed the same object to an agent and the rules above ARE its runtime routing:
const agent = Agent.create({ provider, model })
.skillGraph(buildQuickstartSkillGraph())
.build();You can SEE it
graph.toMermaid() draws the graph you just declared. This block is its verbatim
output from a real run — note the entry edges caption themselves with your
matchers, because the rules are data, not opaque code:
flowchart TD
__start__([▶ start])
n_refunds["refunds"]
n_billing["billing"]
n_triage["triage"]
__start__ -->|/refund#124;money back/i| n_refunds
__start__ -->|charge, invoice| n_billing
__start__ --> n_triagePaste it into a PR description, a README, or mermaid.live
— the routing review IS a picture review. (#124; is mermaid's escape for a |
inside an edge label — it renders back as the | in your regex.)
What those lines bought
Routing by data. match takes a RegExp, { keywords: [...] } (keywords
are case-insensitive, any-of, and whole-word), or { all: [...] } — a conjunction
that fires only when every listed member matches ("zone AND audit-shaped", as data).
From the run above:
| user message | lands in | why |
|---|---|---|
| "I want my money back" | refunds | regex /refund|money back/i |
| "why is there a CHARGE on my card" | billing | keyword charge, case-insensitive |
| "my charger broke" | triage | whole-word: charge does NOT match "charger" — the catch-all rule takes it |
when: (ctx) => ... predicates still work beside match (the catch-all above is
one) — they are the escape hatch for conditions that aren't about the message
text. Each rule takes exactly one of the two.
Tools that follow the graph. scopeTools: true stamps every wired skill with
autoActivate: 'currentSkill', so process_refund is offered to the model only
while the graph is on refunds — not on every iteration from the start. The
default is false (today's behavior) until 10.0.0 flips it.
A check-up you didn't have to write. Because the rules are data, the build
checks them: a rule routing to a skill that isn't in skills[] refuses to build
(rule-id-exists, listing every bad id and the known catalog), two rules that
provably overlap or shadow each other get a warning naming both — and skill
bodies that mention tools are checked against the agent's real tool registry
at Agent....build(), automatically.
Write down the phrasings — examples
Two rules can use two different regexes and still fight over one sentence, and
comparing the regexes cannot settle it: this library never decides regex
intersection, only identity. So write the sentence down instead. examples is the
list of phrasings a rule claims — read at build time, fed to nothing at run time:
{ use: 'refunds', match: /refund|money back/i, examples: ['I want my money back'] },The check-up then runs the compiled matchers over each phrase, in declaration order,
exactly as the cold start does, and reports a witness instead of a theory: the rule
that misses its own example (example-misses-own-rule, error for a data match), the
earlier rule that claims a later rule's phrase (example-shadowed-by-earlier, error — a
real production bug that matcher-comparison had to stay silent about), and the phrase
no rule claims at all, which falls through to the model tier (example-unclaimed,
warning — absence, which no matcher-vs-matcher analysis can catch). Where the answer
depends on how the graph is MOUNTED — an earlier unconditional entry, which the
declaration-order cold start honors and a continuity: 'conversation' cascade skips —
the report warns with both readings (example-shadowed-by-default) rather than assert
one against the router.
These are TEST material, not scoring material: unlike the examples inside
match: { intent, examples } below, they never widen matching and the classifier
never sees them. And the report states its own boundary in checkup().notes — these
checks prove things about the phrases you declared and nothing about phrases nobody
wrote, so no warning is not proof of coverage. Full surface in
Skills.
The routing cascade (9.17.0)
Regex and keywords route the phrasings you predicted. For everything else, declare each start rule's intent as data — one sentence plus real user phrasings — and name one classifier to judge new messages against them:
const = ({
: [, ],
: {
: [
{ : 'billing', : { : 'customer wants a refund',
: ['refund my order', 'charged twice'] } },
{ : 'shipping', : { : 'customer asks where a delivery is',
: ['track my parcel'] } },
],
: (), // no dependency; embeddingScorer(e) / llmClassifier(p) also fit
},
});
const = .({ , : 'claude-sonnet-4-5' })
.(, { : 'conversation', : 'guard' })
.();Every turn now starts through a three-tier cascade, once, off the hot loop:
- Declared rules (regex / keywords /
when) — binary, decisive. - The classifier over your declared intents — with an explicit floor and a near-tie margin. A decisive winner routes; a near-tie falls through instead of coin-flipping.
- A menu — only on declared ambiguity, the closest candidates lead the
read_skilldescription (staying put is a first-class option) and the model picks.
Every verdict is recorded as agentfootprint.skill.turn_routed — the tier that
decided, every candidate's score (the losers too), the runner-up gap, and the
exact thresholds used. graph.checkupIntents() audits your examples with the
configured classifier before you ship them.
When tier 1 decided — a match: rule (RegExp / { keywords } / { all }) —
the verdict also carries the witness: the text out of the user's message
that made the rule true (witness: { text, keyword? }, on turn_routed and on
that hop's cursorMove). The commentary then reads routed this turn to
billing because the message said "chargeback" instead of a rule matched —
one sentence off either record, so a graph with no cascade (where the hop is
the only record) reads exactly the same.
The text is the matched substring of the user message only, whitespace-
collapsed and bounded to 80 characters. A when predicate is opaque code and a
scorer's evidence is its scores, so neither records a witness.
The two mount options:
continuity: 'conversation'— the cursor ridesagent.checkpoint(), sofollowUp()starts where the last turn ended. A sticky default, not a lock: a decisively different message still moves, an unmatched one opens the menu with STAY offered, and a cursor the deployed graph no longer knows is dropped and recorded (turn_routed.droppedResume).strictness— how much routing authority the model has:'assist'(default — today's gate; off-menu picks allowed and stampeddeclinedOffer),'guard'(picks only from an offered menu),'rails'(the model never routes; a menu then proceeds on the base prompt, recorded asby: 'none'— the honest cost of rails without a resolver).
Neither option set = byte-identical to 9.16, events included.
Brains, a decider, and routing on outcome (9.19.0)
The same mount picks who answers and what resolves a menu — and an edge can route on a tool's declared outcome instead of its prose:
import { Agent } from 'agentfootprint';
import { skillGraph, defineSkill, llmClassifier } from 'agentfootprint/context';
import { mock } from 'agentfootprint/providers';
const strong = mock({ reply: 'the strong model answered' }); // any LLMProvider port
const refund = defineSkill({
id: 'refund',
description: 'refund handling',
body: 'Handle the refund.',
// "The cursor picks the brain": refund answers on its own model.
provider: strong,
model: 'strong-model',
});
const triage = defineSkill({ id: 'triage', description: 'first contact', body: 'Triage.' });
const desk = defineSkill({ id: 'desk', description: 'denied refunds', body: 'Escalate.' });
const graph = skillGraph()
.entry(triage, { match: { intent: 'customer wants a refund', examples: ['refund my order'] } })
// Route on MEANING: only a DENIED refund tool result moves to the desk.
.route(refund, desk, { onToolReturn: 'issue_refund', onToolStatus: 'denied' })
.route(triage, refund)
.classify(llmClassifier(mock({ reply: 'refund' })))
.build();
const agent = Agent.create({ provider: mock({ reply: 'small answered' }), model: 'small' })
.system('You are support.')
.skillGraph(graph, {
// N recorded gate refusals in one turn → the rest runs on the big brain.
escalation: { provider: strong, model: 'strong-model', afterRefusals: 2 },
// An outstanding menu resolves out-of-band — the sanctioned rails resolver.
decider: { provider: strong, model: 'strong-model' },
})
.build();
void agent;Every choice is on the record: llm_start.brain says which rung answered,
skill.escalated marks the flip with its evidence, turn_routed { by: 'decider' } names the resolver, and a tool's status rides
stream.tool_end and the routed edge. None declared = byte-identical,
events included.
Next steps
- Skills —
defineSkillitself, plus the full graph surface:matchdetails,scopeTools, the check-up codes,stepsfor mid-turn hand-offs - Skills, explained — why progressive disclosure works, per-provider delivery, and the interactive graph essay
Skills
defineSkill — LLM-activated body + tools. The LLM calls read_skill('billing') to load a body of guidance for the rest of the turn; autoActivate scopes the skill's tools to that window too. The shipped Skills surface today; full conceptual essay in skills-explained.
Skills, explained
Skills are context engineering for instructions — abstracted so you don't do it by hand and get it wrong. A conceptual walk through what they actually are, and why the abstraction exists.
