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 names, and the whole story
The official vocabulary (9.51.0), in one sentence: you declare the SkillMap; the agent is the SkillWalker; the recording carries both.
- DECLARE —
defineSkillMapbuilds the map: skills, edges, entry matchers as data, and guards as data on route edges. It is a permanent alias ofskillGraph— the same function, both names exported forever (SkillMapaliases theSkillGraphtype the same way). Use whichever reads better; nothing was renamed. - ATTACH —
.skillGraph(map). There is deliberately noSkillWalkerclass: the walker is the agent itself, moving its cursor over your map. - WATCH — every recording carries the map (
skill.graph_declared, guard conditions included) and the walk (cursorMoveper iteration, with per-condition guard evidence). The SkillGraph debugger renders both.
The walker moves by exactly three movers:
| mover | who decides | on the record |
|---|---|---|
| llm | the model picks via read_skill, bounded by the gate | by: 'model-pick'; refusals as skill.rejected |
| guard | your data decides — a when / onToolReturn / onToolStatus / guard: edge | by: 'route', with cursorMove.guard when a data guard judged it |
| linear | no choice — a hand-off that fires whenever its source finishes | by: 'route' |
The code
Three defineSkill calls and one skillGraph({ ... }). Start rules are tried top
to bottom; the first that matches the user's message wins.
import { defineTool } from 'agentfootprint';import { defineSkill, skillGraph } from 'agentfootprint/context';const refunds = defineSkill({ id: 'refunds', description: 'Refunds and money-back requests. Unlocks process_refund.', body: 'Confirm the order id first, then call process_refund(orderId).', tools: [ defineTool({ name: 'process_refund', description: 'Issue a refund. Args: { orderId: string }.', execute: ({ orderId }: { orderId: string }) => `Refund for ${orderId} issued.`, }), ],});const billing = defineSkill({ id: 'billing', description: 'Charges, invoices and billing statements.', body: 'Explain the charge line by line before offering anything else.',});const triage = defineSkill({ id: 'triage', description: 'Front desk for everything else.', body: 'Ask one clarifying question, then pick the right skill.',});/** Where a turn enters: first matching rule wins, top to bottom. */export function buildQuickstartSkillGraph() { return skillGraph({ skills: [refunds, billing, triage], start: { rules: [ { match: /refund|money back/i, use: 'refunds' }, { match: { keywords: ['charge', 'invoice'] }, use: 'billing' }, { when: () => true, use: 'triage' }, // catch-all — code works beside data ], }, scopeTools: true, // tools follow the graph's position (default false until 10.0.0) });}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.
Guard a transition with data (9.51.0)
onToolStatus routed on a tool's declared outcome; the general condition was
still an opaque when predicate — code the library can only run. guard: is
its DATA twin, and the last of the walker's three movers to become data:
const = ()
.()
.(, , {
: 'assess_risk', // this tool…
: { : { : ['high', 'critical'] }, // …whose result says so
: { : 0.7 } },
})
.();A guard is { key: { op: value, … }, … } — operators
eq / ne / gt / gte / lt / lte / in / notIn, every condition ANDed
(deliberately footprintjs's WhereFilter grammar). Six hop keys read the
hop itself — toolName, result, status, iteration, userMessage,
currentSkillId — and any OTHER key reads the top-level field of that name
from the tool result parsed as JSON, the shape structured results already
have. At most one of when / guard per edge: code or data, never both.
(One placement note: result-field keys judge the string the model READ,
which artifact placement can replace with a claim ticket — guard on
toolName / status when you want a condition placement cannot move.)
Declaring the condition as data buys four things a predicate can never have:
- the check-up proves contradictions —
guard-unsatisfiableis an ERROR naming the conflict (score > 5 AND score < 3; a guardstatusthe edge's ownonToolStatusexcludes; a status outside the closed vocabulary — the typo'sucess'is caught at build, not discovered as an edge that never fires); - the map draws it —
toMermaid()captions the edge (on assess_risk when riskLevel in [high, critical] AND score ≥ 0.7); - the recording carries it —
skill.graph_declarededges include theguardconditions, so a viewer drawing the SkillMap from a recording shows them without re-reading source; - every decision leaves evidence — when a guard decides a hop, the move
(
context.evaluated.cursorMove) carries the full per-condition evaluation:cursorMove.guardon the taken hop (verdicttrue, each condition with the summarized value it saw),cursorMove.guardsClosedon a refusal (verdictfalse— so "why didn't my edge fire?" is a lookup: the record saysscore gte 0.7 — saw "0.2" → failed).
Runnable end to end (both walks, evidence printed):
examples/features/64-skill-map-guards.ts.
The compiled shapes are exported as SkillGuard (the author form — keys to
SkillGuardOps, the per-key operator set, whose thresholds are plain
GuardValue data), SkillGuardData (the serializable description on
SkillEdge.guard and the declared map — a list of GuardConditionData
rows, each one key + one GuardOperator + one value), and GuardEvaluation
(the evidence on the move, whose per-condition rows are
GuardConditionEvidence — the condition plus the summarized value it saw
and whether it passed) — with plainGuardCaption naming any guard in prose
and GUARD_HOP_KEYS listing the six hop keys.
Running the graph without our agent (9.34.0)
The skill graph is a decision layer, not a runtime. Given one iteration's
InjectionContext it says where the cursor goes, what is reachable from
there, and which injections are active — plain functions over plain data.
That layer ships at its own subpath:
import { skillGraph, readSkillDescriptor } from 'agentfootprint/skill-graph';Three things are worth stating plainly.
The neutrality is proved, not claimed. A test —
test/lib/injection-engine/skill-graph-fence.test.ts — walks the transitive
import graph of everything this subpath exports and fails if any of it
reaches footprintjs, the agent loop, the tool factory, an adapter or a
recorder. It is a fence, and it is the actual feature: the boundary already
pointed the right way, but nothing enforced it, so it had been eroding one
free import at a time.
footprintjs is still a required peer dependency of this package. It is
not marked optional, so npm installs the flowchart engine beside you even if
this subpath is the only path you import. The door never loads it — that is
what the fence proves — but the package is not split, and the rest of
agentfootprint needs the engine. You pay the install, not the import.
The provider layer is deliberately outside the boundary. llmClassifier
and constrainedEnumPick make a model call, so they need an LLMProvider;
they live on agentfootprint/context instead. So do the sugar factories
(defineSkill and friends), which validate against the framework's Tool. A
foreign host builds Injection objects directly — five fields, all data.
What a host owes the graph is written down as a type, SkillGraphHost: five
obligations (advance the cursor once per iteration off the same context the
triggers read; enforce reachableSkills at pick time; publish an accepted
pick only after acceptance; carry the cursor forward; emit the decisions),
each naming the code in this package that implements it. It is documentation
that typechecks — not a second way to run an agent.
The boundary contract, name by name
Everything the subpath adds is data or a description — nothing here runs a loop.
SkillGraphHostis the obligations interface above.SkillGraphIterationContextis the small per-iteration shape itsadvanceCursorreads (your realInjectionContextsatisfies it).SkillToolis the narrow tool shape a skill carries — aschemaand anexecute, nothing else required — sodefineSkill({ tools })no longer needs the framework's ownTooltype.SkillToolSchemais the{ name, description, inputSchema }triple inside it.SkillToolDescriptoris a tool the graph describes rather than builds.readSkillDescriptor(skills, offer?)andlistSkillsDescriptor(skills)return theread_skill/list_skillsdescriptors — enum, reachability offer, turn-start menu, result sentences — andskipStepDescriptor()returns theskip_stepone. Hand any of them to your own tool factory; inside agentfootprint,buildReadSkillToolis the one line that passes it todefineTool.ReadSkillOfferis the shape that narrows the description to what the gate will actually grant. A host wiring its ownread_skillunder a decisiontree()must settreeRouted: trueon it: a tree routes by predicate on every iteration and keeps no cursor, so every routing pick is refused by construction, and the flag is what lets the descriptor withhold the offer (with open skills present it explains the tree and lists what a pick can open instead). Without it the host keeps offering a menu the graph will reject.SkillCachePolicy(with itsSkillCachePolicyContext) is the cache directive anActiveInjectioncarries across the boundary — the same'always' | 'never' | 'while-active' | { until }your cache layer knows, spelled without importing one. A host with no prefix cache ignores it.useSkillGraphDevMode(read)turns the graph's dev warnings on in a host that is not ours;DevModeReaderis the() => booleanit takes. Inside agentfootprint this is already wired toenableDevMode(), so you never call it.
Nobody has yet driven this from another framework. The fence is a fact about the code, not about that experience.
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.
Skill graph architecture
The three surfaces, the authority rule, and the cursor as a program counter — every claim carrying a status, and one worked refusal taken from a real run.
