Build

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.

An agent with more than one job needs somewhere to keep the instructions for each job, and something that decides which set is in force right now. This page is the architecture of that answer in agentfootprint: what surfaces a model reads, who is allowed to write to each of them, and what moves the cursor.

It is the long-form companion to the two shorter pages either side of it — the 5-minute quickstart is the code, Skills, explained is the concept, and the Skills guide is the full API surface. This page is the mechanism between them.

How to read the status column

Every capability claim on this page carries a status. That is deliberate: the previous version of this document was written in specification voice, so an aspiration and a shipped behaviour read identically, and four load-bearing statements turned out to be wrong. A status column cannot fix a wrong sentence, but it makes an aspiration unable to wear the same clothes as a fact.

StatusMeans
shippedPresent in 9.36.0 and on by default. Calling it is enough.
opt-inPresent in 9.36.0 and off until you ask for it by name. The default behaviour is the older one.
application-providedThe library gives you the seam and the vocabulary. The behaviour is code you write.
plannedDesigned and not built. Do not call it; it does not exist.

Chapter 0 — Three surfaces, and who writes to them

The three surfaces

An LLM API call accepts exactly three places to put content:

┌────────────────┬─────────────────────┬─────────────┐
│ system prompt  │       messages      │    tools    │
└────────────────┴─────────────────────┴─────────────┘

Everything a framework calls a "feature" — retrieval, memory, skills, tool gating, few-shot examples — is content placed into one of those three, under some condition. agentfootprint has one primitive for that (Injection) and named factories on top of it.

SurfaceWhat a skill puts thereRebuilt each iteration?Status
system promptthe active skill's body (its standing instructions)yes, under the default reactMode: 'dynamic'shipped
toolsthe active skill's tools (its capabilities)yes, under the default reactMode: 'dynamic'shipped
messagestool results, and slot: 'messages' injections the Deliver stage appendsno — a message is written once, at the position where it happenedshipped

The middle column is the whole of this chapter. Two of those surfaces are recomposed; one accumulates.

Action tools and navigation tools

A skill graph puts two different kinds of tool on the wire, and confusing them is the most common way to misread a run.

KindExamplesWhat the call doesStatus
action toollookup_order, issue_refund — tools you wrotedoes something in the world and returns the answer. The result is the point.shipped (you supply the tools)
navigation toolread_skill, list_skills, skip_step — auto-attached by the librarychanges what the model will be told next turn. The result is a receipt; the effect lands on the next iteration's surfaces.shipped

read_skill is the load-bearing navigation tool, and its execute is deliberately almost empty — it returns a confirmation string (or, under surfaceMode: 'tool-only' / 'both', the skill body verbatim). It does not move anything itself. The loop that saw the tool call is what checks the pick against the graph and moves the cursor. That split is written down as a type; see obligations 2 and 3 in chapter 3.

list_skills and read_skill are auto-attached whenever at least one skill is registered. skip_step is auto-attached whenever at least one registered skill declares steps, and is offered in the request only while a stepped skill is active and unfinished.

The authority rule

A tool result is written into the conversation once and from then on only gets older. The system prompt is rebuilt from nothing on every iteration. So the instructions that must still be in force on iteration nine belong in the surface that is recomposed — not in the surface that ages.

This is the sharpest thing in the whole design, and the code is unambiguous about the mechanism:

  • reactMode: 'dynamic' is the default, and it "re-runs the InjectionEngine and all three slots (system-prompt ‖ messages ‖ tools)" on every iteration. The active skill's body is therefore re-projected into the system prompt each turn, for as long as the cursor is on it. shipped
  • reactMode: 'classic' engineers context once and caches the system-prompt and tools slots after turn 1. Its own documentation says, in as many words, do not use it with skills: a mid-run activation would never surface. That is the authority rule stated as a caveat. shipped
  • surfaceMode picks which surface a body lands on. 'system-prompt' (and the 'auto' default) put it where it is re-projected. 'tool-only' delivers it once, as the read_skill tool result, and it then ages like any other message. 'both' does both. shipped

And the limitation, stated in the same breath rather than in a footnote: there is no automatic re-surfacing of an ageing body. refreshPolicy — the field designed to re-deliver a body past a token threshold — is recorded on the skill's metadata and has never been read by the engine on any version. It is deprecated pending a steps-as-data replacement, dev mode warns once when you set it, and nothing happens. If you need a body re-surfaced in a long run today, use surfaceMode: 'both' so every read_skill call returns it afresh, or keep it in the system prompt where it is rebuilt anyway. planned (re-delivery); shipped (surfaceMode: 'both').

Worked example — the same body, two surfaces

// Re-projected into the system prompt every iteration, for as long as the
// cursor is on this skill. The model is re-told the rule on turn 9.
const  = ({
  : 'billing',
  : 'Refunds, charges and invoices.',
  : 'Look the order up before you touch money. Never promise a refund first.',
  : [],
  : 'system-prompt',
});

// Delivered ONCE as the read_skill tool result, then it ages with the rest of
// the transcript. Cheaper, more recent on arrival, and gone from attention later.
const  = ({
  : 'escalation',
  : 'How to page the on-call engineer.',
  : 'Page on-call only for a data-path outage.',
  : 'tool-only',
});

const  = .({ , : 'claude-sonnet-4-5' })
  .()
  .()
  .();

The choice is a real trade-off, not a best practice: the system prompt costs tokens on every single call and is re-read every turn; the tool result costs tokens once and is most salient the moment it arrives. Pick per skill.


Chapter 1 — Thirty APIs are not thirty skills

What a skill is sized to

The tempting mapping is one skill per endpoint. It is wrong for the same reason one function per SQL statement is wrong: it produces a catalog the model has to re-derive a procedure from, on every turn, from names alone.

A skill is sized to a bounded capability — the thing you would hand a new colleague as one job. "Billing" is a skill. POST /refunds is a tool. Thirty APIs are usually four or five skills and thirty tools.

The practical test the library can actually check for you: a skill's body should read as a procedure, and the tools it names should be the tools it unlocks. checkup() runs that as a deterministic, no-model pass and reports body-unknown-tool (the body calls something that exists nowhere) and body-foreign-tool (the body calls a tool that belongs to a different skill). Both are warnings — they never fail a build, because a graph the model can still navigate is not broken. shipped

Deterministic rules stay deterministic

Nothing about a skill graph asks a model to decide something you already know.

MechanismDecided byStatus
match: /refund|money back/i on a start rulea regular expressionshipped
match: { keywords: ['charge', 'invoice'] }whole-word, case-insensitive, any-ofshipped
match: { all: [...] }a conjunction — every listed member must matchshipped
when: (ctx) => … on an entry or routeyour predicate, given the whole InjectionContextapplication-provided
onToolReturn: 'get_invoice' on a route edgea named tool returning at allshipped
onToolStatus: 'denied' on a route edgea tool result's declared outcome, from a six-value vocabularyshipped
match: { intent, examples } + classifya scorer you name — keyword, embedding, or a modelopt-in

The first six put no model in the routing loop. Only the last one does, and it is off unless you configure classify. A rule declared as data also draws itself: graph.toMermaid() captions the entry edges with your matchers, so the routing review is a picture review.

The honest version of "extracted, not invented"

There is a strong claim in circulation about skill graphs: that an agentic application is extracted from a workflow you already have, not invented. The code does not support the strong version, and this page will not make it.

The graph is provenance-agnostic. skillGraph() compiles a graph you extracted from a real runbook and a graph you invented at your desk into byte-identical output. Nothing inspects where the structure came from, and no check-up code can tell them apart.

What the runtime actually rewards is weaker and true: a graph must be DECLARED, and declaring it is what forces the extraction. You cannot get an entry rule to route without writing the phrasings down. You cannot get a route edge to fire without naming the tool return that justifies it. You cannot pass checkup() with a skill nobody wired. The declaration is where the tacit workflow becomes reviewable — which is the real benefit, and it is available whether or not the workflow pre-existed.

Where the code IS opinionated is the tools. A SKILL.md file may name tools; it may never define one. tools: in frontmatter is a list of strings, matched against tool.schema.name in a registry you constructed in your own source from your own imports. A name the registry does not carry is refused at load, by name. So the set of things a directory of skill files can do is a strict subset of what the calling file already decided to do — reading a directory can never introduce a capability, name a module, or cause one byte of new code to run. shipped

Worked example — a runbook you already have on disk

Since 9.36.0 a SKILL.md carries all three parts of a runbook: what to do (prose), what to do it with (tool names), and in what order (steps).

skills/billing/SKILL.md
---
name: billing
description: Use for refunds, charges and billing questions.
tools: lookup_order, issue_refund
steps:
  - lookup_order: look up the order before touching money
  - issue_refund: refund only what the lookup found
onSkip: hold
---
When handling billing: confirm identity first, then …
const  = await ('./skills', {
  // The half a markdown file cannot supply. The file PICKS; you decide
  // what there is to pick from.
  : [, ],
});

const  = .({ , : 'claude-sonnet-4-5' })
  .({ : () =>  })
  .() // billing's tools appear when billing does
  .();

Two statuses on that snippet, and they matter:

ClaimStatus
skillsFromDir loads prose bodies from SKILL.md filesshipped
the same files carry tools:, steps: and onSkip:shipped (9.36.0 — before that, prose only)
the same files carry routes:, read by runbookFromDir into { skills, steps }shipped (9.43.0). skillsFromDir refuses a file that declares routing rather than dropping it
a routes: guard can be a when predicatenot supported, by design — a predicate is code, nothing in a SKILL.md is evaluated; the file picks on <tool> / status=<outcome>, and any other conditional stays in skillGraph({ steps })
.toolsFromActiveSkill() scopes every skill's tools to that skill's activationopt-in (default flips in 10.0.0)
a loaded skill can set its own autoActivate from frontmatternot supported, by design — a directory does not decide the agent's tool posture
skillsFromDir reads a URLrefused by name — authorship is a security property, so local paths only

.toolsFromActiveSkill() governs the offer, not dispatch: a tool stays resolvable by name so an active skill's call lands. If you need execution itself gated — an inactive skill's tool refused even when the model names it from a restored transcript — that is a PermissionChecker, and it is a different question: authority to run, versus what the model was shown. application-provided.


Chapter 2 — The book and the cursor

The model

A skill graph is a book whose chapters are revealed on demand. Each skill is a chapter: a body plus the tools that chapter's work needs. Only the chapter you are on is open, so the token cost is one chapter and not the whole book, and graph.toMermaid() draws the table of contents.

The cursor is which chapter is open. It is sticky — you stay in a skill until something takes you out — and it is the reason routing rules can be from-gated: an edge A → B fires only while the cursor is on A.

The useful upgrade on the metaphor: the cursor is a program counter, not a per-turn classifier. A classifier answers "what is this message about?" once and starts over next time. A program counter carries state forward, can be moved by evidence that arrives mid-run, and can decline to move at all. All three of those happen here.

Nine causes for a cursor move

The runtime records nine distinct causes. They are the CursorMoveCause union, and they arrive on context.evaluated as cursorMove.by. routeRecorder() reports eight of them — every one except 'none', which names the absence of a cursor and so has no hop to record — plus 'rejected' for a pick the gate refused.

Anyone who has read a three-tier description of skill-graph routing has read about three of these. The interesting ones are the other six.

cursorMove.byThe cursor moved because…Status
'entry'cold start: the first entry whose rule passed. On a cascade graph, a tier-1 start rule won the turn.shipped
'route'a declared, from-gated edge fired — a tool returned, or returned with a declared status.shipped
'tool-proposal'a tool result proposed a transition and the graph accepted it. Deterministic tool code, ranked below the author's declared edges and above the model's guess.opt-in (a tool must emit a propose-transition effect)
'model-pick'no declared edge fired, so the model's gate-accepted read_skill pick moved it.shipped
'intent'the turn-start cascade's tier-2 scorer decisively routed the turn.opt-in (needs classify)
'continuity'the cursor inherited from the previous turn held the start of this one.opt-in (needs continuity: 'conversation')
'decider'a configured out-of-band decider resolved an outstanding menu before the loop started.opt-in (needs decider)
'stay'nothing fired. The cursor is sticky and stayed exactly where it was.shipped
'none'there is no cursor at all — a cold start with nothing to enter, or a decision tree(), which routes by predicate and has no cursor to move.shipped

Three of those are what make it a program counter rather than a classifier:

  • 'continuity' — the cursor survives the turn boundary. Without it a follow-up ("and refund it") starts cold and is routed on four words with no context.
  • 'stay' — declining to move is a first-class, recorded outcome, not the absence of one. A run that stayed in triage for six iterations has six recorded 'stay' decisions, which is what lets you tell "it decided to stay" from "nothing was evaluated".
  • 'tool-proposal' — a tool result can move the cursor. That is evidence arriving mid-run and changing what happens next, which no turn-start classifier can express.

Precedence, when several want the cursor at once

The model can emit a domain tool call and a read_skill in one message, and a tool can propose a transition in the same batch. The order is fixed:

a declared edge that fired  >  an accepted tool proposal  >  the model's pick  >  stay where you are

The author's declared route always wins — a model guess never overrides determinism you pinned, and deterministic tool code outranks a guess. Nothing is silently dropped: a suppressed pick emits agentfootprint.skill.reroute_superseded with what was picked and what won, and two results of one parallel batch matching edges to different targets emit agentfootprint.skill.route_conflict with the winner and the suppressed losers. shipped

What the cursor's scope actually is

Per run, by default. graph.nextSkill(ctx) and InjectionContext.currentSkillId describe where the graph is inside one agent.run(). A second run() starts cold at the entry, whatever the first run ended on. shipped

Per conversation, opt-in. .skillGraph(graph, { continuity: 'conversation' }) restores the inherited cursor and judges it as a candidate: the incumbent is beaten only by a decisive verdict, ambiguity means stay, and an inherited id the mounted graph does not know is dropped to a cold start, dev-warned, and recorded — never silently parked on a node that does not exist. opt-in

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', // followUp() starts where the last turn ended
    : 'guard', // the model routes only from an offered menu
  })
  .();

Where the tiers actually are

The turn-start cascade has three tiers, and the classifier is not at tier 3. The code labels its own tiers:

TierWhat decidesStatus
tier 1declared start rules — regex, keywords, when — in declaration order. Binary and decisive.shipped
tier 2the configured scorer: either the intent classifier over declared intents, or the entry scorer over descriptions. Judged by a floor plus a top-two pairwise margin; near-ties fall through rather than argmax.opt-in
tier 3a menu the model resolves in-band, through read_skill's own description, with STAY first-class mid-conversation.opt-in (fires only when tier 2 declines)

The LLM classifier (llmClassifier) is a tier-2 strategy — one of three interchangeable scorers, beside keywordScorer() and embeddingScorer(). It is not a separate tier, and putting it at tier 3 inverts the design: tier 3 exists precisely for the case where no scorer was decisive.

The cascade is also zero-cost when unused. A graph with no classify and no continuity: 'conversation' mounts no stage, writes no key, and emits no new event.


Chapter 3 — The library mapping

The one run door

const  = .({ , : 'claude-sonnet-4-5' }).();

const  = await .({ : 'where is my parcel?' });

agent.run(input, options?) is the door.

SymbolShapeStatus
AgentInput{ message: string; identity?: MemoryIdentity; continueFrom?: AgentRunCheckpoint }shipped
AgentOutputstringshipped
agent.run(...) returnsAgentOutput | RunnerPauseOutcome — a run that paused for a human returns a checkpoint, not a string. Discriminate with isPaused(result).shipped
optionsAgentRunOptionssessionId, correlationId, traceId, identity, …shipped

run() is one turn. Without continueFrom it seeds history from this call's message alone, so a second run() starts a new conversation and the model will honestly say it has not spoken to you before. agent.followUp(message) is the same thing for the common case.

The concept-to-symbol map

Concept in this pageReal symbolImport fromStatus
define a chapterdefineSkill({ id, description, body, tools?, surfaceMode? })agentfootprint/contextshipped
load chapters from filesskillsFromDir(dir, { tools?, surfaceMode? })agentfootprint/contextshipped
load the whole runbook — chapters AND their edgesrunbookFromDir(dir, opts?)DirRunbookagentfootprint/contextshipped (9.43.0)
pin that a phrase routes NOWHEREneverRoutes: [...] / .neverRoutes(...)agentfootprint/contextshipped (9.43.0)
declare the bookskillGraph() (fluent) / skillGraph({ skills, start?, steps? }) (object)agentfootprint/contextshipped
wire it to an agent.skillGraph(graph, { continuity?, strictness?, … })agentfootprintshipped
draw itgraph.toMermaid()shipped
lint itgraph.checkup(), formatCheckup(result)agentfootprint/contextshipped
ask where the cursor goesgraph.nextSkill(ctx) / graph.explainNextSkill(ctx)shipped
ask what is reachablegraph.reachableSkills(currentSkillId?)shipped
scope tools to the active skill.toolsFromActiveSkill()agentfootprintopt-in
require evidence for names and numbers.namesAndNumbersFromEvidence({ posture })agentfootprintopt-in
record the path the run tookrouteRecorder()agentfootprint/observeshipped
run the graph from a non-agentfootprint hostagentfootprint/skill-graph subpathagentfootprint/skill-graphshipped (import-graph proven; ergonomics unproven)

read_skill has a three-way design

This is the most carefully reasoned surface in the routing layer, and it is usually described as one list. It is three.

For any given iteration, a registered skill is in exactly one of three states from the model's point of view:

StateIn the tool description?In the tool enum?Why
reachableyes — under Reachable from here:yesit is what the gate will grant from this cursor
refusableyes — under Not reachable from here (read_skill for these will be refused):yesa graph refusal is about where the cursor is. The skill exists for this caller; it is simply not reachable from here. Naming it lets the model route to it in one step instead of guessing.
hiddenno — not named at allyesa hidden skill is about who is asking. No cursor move makes it available, and naming it would tell a role about a capability it will never be allowed to use — leaking the shape of somebody else's permissions into this model's prompt.

The two treatments are opposite on purpose, and the reason is that they answer different questions. "Not reachable from here" is navigable information. "You may never see this" is not information the model should have.

The enum keeps the full catalog in every case, including the hidden skills, and that is not an oversight. Tool-argument validation runs before the skill-graph gate. Narrowing the enum would turn a policy refusal into a generic schema error that the model never reads — retiring the gate's teaching refusal, the agentfootprint.skill.rejected event, routeRecorder()'s rejection hops and the rejected-cap governor's only input, all four traded for one. The offer is narrowed in the description instead, which is what the model actually reads to choose.

BehaviourStatus
the description is rebuilt each iteration from the same reachability the gate enforcesshipped
refusable skills are named rather than hiddenshipped
hidden skills are removed from the description entirelyopt-in — requires a PermissionChecker that governs the skill_read capability; without one, nothing is hidden
the hidden set is resolved per iteration, and fails closed if the checker throwsshipped
the enum is the full catalog either wayshipped
under reactMode: 'classic' the menu is built at turn 1 and cached, so it lists everythingshipped limitation — dev mode warns; use the default 'dynamic'

What a host owes the graph

The graph is a pure decision layer: it never sees a tool call, so it cannot enforce its own gate. What a host must do is written down as a type, SkillGraphHost, with five obligations. It is documentation that type-checks — nothing constructs one and nothing consumes one.

ObligationWhat it saysStatus
1 — one advance per iterationbuild the iteration's context once, ask the graph where the cursor goes with that exact object, and evaluate every trigger with that exact object. The keystone.shipped as a type; agentfootprint's own loop is the reference implementation
2 — enforce reachability at pick timecheck read_skill's id against reachableSkills(cursor) before treating the pick as real, and refuse teachingly. A host that skips this turns every declared edge into a suggestion.shipped
3 — publish only an accepted pickset pendingSkillPick only after obligation 2 said yes, and clear it every iteration.shipped
4 — carry the cursor forwardthis iteration's answer is the next iteration's currentSkillId.shipped
5 — say what happenedemit activation, deactivation, refusal, and batch conflicts. A refusal the operator cannot see is the one this contract cares about.shipped

Two honest costs of the agentfootprint/skill-graph subpath, stated where the claim is made. footprintjs remains a required peer dependency — this door never loads it (a test walks the transitive import graph and fails on any edge that reaches it), but npm installs it beside you because the package is not split. You pay the install, not the import. And nobody has yet run this from another framework: the fence proves the import graph, which is a fact about the code; it does not prove ergonomics, which is a fact about experience we do not have.


One worked failure

Every example above succeeds. This one does not, which is the point: a refusal is the mechanism you most need to recognise in a transcript, and it is the mechanism almost never shown.

The setup: a three-skill graph where audit-log is reachable only from billing. The model, on iteration 1, tries to jump straight there from triage.

import { ,  } from 'agentfootprint';
import { ,  } from 'agentfootprint/context';
import {  } from 'agentfootprint/providers';

const  = ({
  : 'get_invoice',
  : 'Fetch an invoice by id',
  : { : 'object', : { : { : 'string' } } },
  : async () => ({ : 'INV-1', : 42 }),
});

const  = ({
  : 'triage',
  : 'Start: work out what the user needs',
  : 'Triage it.',
});
const  = ({
  : 'billing',
  : 'Refunds, charges and invoices',
  : 'Handle billing.',
  : [],
});
const  = ({
  : 'audit-log',
  : 'Read the audit log',
  : 'Read the audit trail.',
});

const  = ()
  .()
  .(, , { : 'get_invoice' })
  .(, , { : 'get_invoice' }) // ONLY from billing
  .();

// Scripted so the failure is deterministic: iteration 1 jumps out of reach.
let  = 0;
const  = ({
  : () => {
    ++;
    if ( === 1) {
      return {
        : 'Let me open the audit log.',
        : [{ : 'c1', : 'read_skill', : { : 'audit-log' } }],
        : 'tool_use' as ,
      };
    }
    return { : 'Understood — staying in triage.', : 'stop' as  };
  },
});

const  = .({ , : 'mock', : 4 })
  .('You are a support assistant.')
  .()
  .();

const  = await .({ : 'show me the audit log for my last invoice' });

What the model was offered

Verbatim, the read_skill description on iteration 1. The cursor is on triage, so billing is reachable and the other two are named as refusable:

Activate a skill for the next iteration.

Reachable from here:
  - billing: Refunds, charges and invoices

Not reachable from here (read_skill for these will be refused):
  - triage: Start: work out what the user needs
  - audit-log: Read the audit log

Pass the skill's id. The skill's body becomes part of the system prompt and any gated tools become available on the next call.

It asked for audit-log anyway. Models do.

What the model got back

Verbatim, the tool message on iteration 2 — the entire feedback the model receives:

read_skill("audit-log") is not reachable from here. Reachable skills: billing. Pick one of these, or finish.

One sentence, and it names what is allowed rather than only what is not, because a refusal that does not teach just moves the puzzle. There are three shapes of this sentence: this one (something is reachable), a dead-end variant ("No skills are reachable from here — answer with the current skill, or finish"), and a decision-tree variant that explains a tree has no cursor to move at all.

What the record says

The run emitted one agentfootprint.skill.rejected:

{
  "requestedId": "audit-log",
  "currentSkillId": "triage",
  "allowed": ["billing"],
  "iteration": 1
}

And, critically, the cursor did not move. Both iterations' cursorMove on agentfootprint.context.evaluated:

[
  { "activeIds": ["triage"], "cursorMove": { "to": "triage", "by": "entry" } },
  { "activeIds": ["triage"], "cursorMove": { "from": "triage", "to": "triage", "by": "stay" } }
]

Iteration 1 is by: 'entry' (cold start). Iteration 2 is by: 'stay' — not 'model-pick', because the pick was refused, and not absent, because declining to move is a recorded decision. The final cursor is triage, audit-log never activated, and the answer came out of triage.

That is the whole invariant in one run: a refused pick is answered in words the model can act on, is on the record as an event, and moves nothing.

You can watch it yourself with a recorder:

const  = ();
const  = .({ , : 'claude-sonnet-4-5' }).().();

await .({ : 'show me the audit log' });

.(); // the skill sequence
.(); // per hop: { fromSkill, toSkill, outcome, why, edgeLabel, lastTool }
.(); // out-of-reach read_skill attempts
.(); // governor trips: oscillation, and a run of refusals

getHops().outcome is RouteOutcome, which is nine values — the eight cursor causes that name a hop ('entry', 'route', 'tool-proposal', 'model-pick', 'intent', 'continuity', 'decider', 'stay') plus 'rejected' for a pick the gate refused. 'none' is deliberately absent: it means there is no cursor, so there is no hop to record. An exhaustive switch over RouteOutcome needs those nine cases.


Four things that do not exist

These are not hypothetical mistakes. A capable author working from a correct mental model of this system got all four wrong in one document, which makes them measured evidence about what a reader — human or model — will invent here.

The inventionThe reality
startRun(...)There is no startRun. The door is agent.run(input, options?) with AgentInput = { message, identity?, continueFrom? }.
RunStep is the skill historyRunStep is real and it is something else entirely — the footprintjs flowchart topology slider. Its kind is 'sequential' | 'fork' | 'merge' | 'decide' | 'iteration' | 'iteration-exit' | 'react'. Nothing in it is about skills. It is dangerous precisely because importing it succeeds. For skill history, use routeRecorder().
the LLM classifier is tier 3It is a tier-2 strategy, interchangeable with keywordScorer() and embeddingScorer(). Tier 3 is the menu the model resolves in-band through read_skill, and it exists for the case where tier 2 was not decisive.
a skill's tools are gated to that skill automaticallyThey are not, by default. defineSkill({ tools }) puts them in the agent's static tool list at build time, visible from iteration 1 whether the skill is ever activated or not. Ask for the gate: .toolsFromActiveSkill() on the agent, skillGraph({ scopeTools: true }) on the graph, or autoActivate: 'currentSkill' per skill. .tree() leaves are the one shape that scopes by default. The default flips in 10.0.0.

Two more absences worth naming while you are here:

  • There is no runtime force-stop governor. routeRecorder().getTrips() labels a spinning run — oscillation, a run of refusals — and the iteration cap is the only hard stop. planned.
  • There is no automatic re-delivery of an ageing skill body. See the authority rule above. planned.

On this page