Build

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 if statements. 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.

  1. DECLAREdefineSkillMap builds the map: skills, edges, entry matchers as data, and guards as data on route edges. It is a permanent alias of skillGraph — the same function, both names exported forever (SkillMap aliases the SkillGraph type the same way). Use whichever reads better; nothing was renamed.
  2. ATTACH.skillGraph(map). There is deliberately no SkillWalker class: the walker is the agent itself, moving its cursor over your map.
  3. WATCH — every recording carries the map (skill.graph_declared, guard conditions included) and the walk (cursorMove per iteration, with per-condition guard evidence). The SkillGraph debugger renders both.

The walker moves by exactly three movers:

moverwho decideson the record
llmthe model picks via read_skill, bounded by the gateby: 'model-pick'; refusals as skill.rejected
guardyour data decides — a when / onToolReturn / onToolStatus / guard: edgeby: 'route', with cursorMove.guard when a data guard judged it
linearno choice — a hand-off that fires whenever its source finishesby: '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.

components/demos/skillGraphQuickstartDemo.ts — the exact graph drawn belowdeclared === drawn
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)  });}
Interactive graph loads as you approach it.

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_triage

Paste 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 messagelands inwhy
"I want my money back"refundsregex /refund|money back/i
"why is there a CHARGE on my card"billingkeyword charge, case-insensitive
"my charger broke"triagewhole-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:

  1. Declared rules (regex / keywords / when) — binary, decisive.
  2. 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.
  3. A menu — only on declared ambiguity, the closest candidates lead the read_skill description (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 rides agent.checkpoint(), so followUp() 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 stamped declinedOffer), 'guard' (picks only from an offered menu), 'rails' (the model never routes; a menu then proceeds on the base prompt, recorded as by: '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 contradictionsguard-unsatisfiable is an ERROR naming the conflict (score > 5 AND score < 3; a guard status the edge's own onToolStatus excludes; a status outside the closed vocabulary — the typo 'sucess' is caught at build, not discovered as an edge that never fires);
  • the map draws ittoMermaid() captions the edge (on assess_risk when riskLevel in [high, critical] AND score ≥ 0.7);
  • the recording carries itskill.graph_declared edges include the guard conditions, 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.guard on the taken hop (verdict true, each condition with the summarized value it saw), cursorMove.guardsClosed on a refusal (verdict false — so "why didn't my edge fire?" is a lookup: the record says score 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.

  • SkillGraphHost is the obligations interface above. SkillGraphIterationContext is the small per-iteration shape its advanceCursor reads (your real InjectionContext satisfies it).
  • SkillTool is the narrow tool shape a skill carries — a schema and an execute, nothing else required — so defineSkill({ tools }) no longer needs the framework's own Tool type. SkillToolSchema is the { name, description, inputSchema } triple inside it.
  • SkillToolDescriptor is a tool the graph describes rather than builds. readSkillDescriptor(skills, offer?) and listSkillsDescriptor(skills) return the read_skill / list_skills descriptors — enum, reachability offer, turn-start menu, result sentences — and skipStepDescriptor() returns the skip_step one. Hand any of them to your own tool factory; inside agentfootprint, buildReadSkillTool is the one line that passes it to defineTool. ReadSkillOffer is the shape that narrows the description to what the gate will actually grant. A host wiring its own read_skill under a decision tree() must set treeRouted: true on 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 its SkillCachePolicyContext) is the cache directive an ActiveInjection carries 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; DevModeReader is the () => boolean it takes. Inside agentfootprint this is already wired to enableDevMode(), 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

  • SkillsdefineSkill itself, plus the full graph surface: match details, scopeTools, the check-up codes, steps for mid-turn hand-offs
  • Skills, explained — why progressive disclosure works, per-provider delivery, and the interactive graph essay

On this page