Build

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.

A support agent handles billing 5% of the time and tech 80% of the time and refunds 15% of the time. Putting all three playbooks in the system prompt wastes tokens 95% of the time billing isn't relevant. Putting them behind a tool the LLM calls on demand is what defineSkill does — the body lands only when the LLM asks for it, and with autoActivate the skill's tools follow the same window.

What a Skill is

A Skill is the llm-activated flavor of the Injection primitive. It bundles:

  • A body — the playbook text the LLM follows when the skill is active
  • A set of tools — the capabilities that go with the playbook. They are registered with the agent up front and callable from iteration 1 unless you add autoActivate: 'currentSkill', which scopes them to the window where the skill is active
  • A description — what the LLM sees BEFORE activating (used to decide whether to read the skill)

The library auto-attaches a read_skill tool to the agent. The LLM activates by calling read_skill('billing'); the framework looks up the skill, formats the body as the tool result, and lets the LLM use it on the next iteration.

Define and attach a skill

const billingSkill = defineSkill({  id: 'billing',  description: 'Read for refund / charge / billing questions. Covers process_refund.',  body: 'When handling billing: confirm the order id, then call process_refund. Always state the amount + payment method in the final reply.',  tools: [refundTool],  // Without this line, process_refund would sit in the agent's tool list from  // iteration 1 — activation adds the BODY, not the tools. `currentSkill`  // keeps the tool out of the list until the LLM has read the billing skill.  autoActivate: 'currentSkill',});

The body reads the way you'd brief a junior employee. The autoActivate: 'currentSkill' line is what keeps process_refund out of the tool list until the LLM has explicitly chosen to handle billing — drop that line and process_refund is offered from iteration 1, activated or not, because activation adds the body and the tools were registered at build time. Gating tools is opt-in, not the default.

Why progressive disclosure matters

Three reasons:

  1. Token cost — skill bodies are LARGE (200–2000 tokens of playbook text). Loading all of them on every turn is wasteful. Loading on demand is amortized.
  2. Context discipline — the LLM sees ONLY the playbook for what it's currently doing. No cross-domain confusion.
  3. Capability scoping — with autoActivate: 'currentSkill', process_refund is offered to the LLM only while billing is active, so it isn't sitting in the tool list tempting a mistaken call during an unrelated turn. This is a noise-reduction measure, not a security boundary: it decides what the model is shown, while the tool stays resolvable by name in the dispatch registry. Enforce real permissions with gatedTools.

This is the pattern Anthropic shipped as "Agent SDK Skills" — agentfootprint reimplements it with cross-provider correctness via the Injection primitive so it works on mock(), OpenAI, Bedrock, Ollama identically.

When a skill activates

The LLM's tool-call to read_skill({ id: 'billing' }) is the activation event. The framework:

  1. Looks up billing in the agent's registered skills
  2. Formats the body as a tool result the LLM sees on the next iteration
  3. Starts offering billing.tools in the tool list for the rest of the turn — but only if billing set autoActivate: 'currentSkill'. Without it those tools were already in the registry before the LLM asked, and this step changes nothing
  4. Emits agentfootprint.skill.activated for observability

The skill stays active until the agent run ends — there is no mid-turn deactivation: nothing removes an id from the activated set once read_skill added it, and an unrelated tool turn later in the run does not end it. The next agent.run() starts fresh. If a body should stop applying mid-turn, that is a rule trigger (activeWhen) or a skillGraph() route — conditions re-evaluated every iteration — not a read_skill pick.

Inside a skill graph, the pick also MOVES the graph

When the agent was built with .skillGraph(...), read_skill is bounded to the skills reachable from where the graph currently stands (graph.reachableSkills), and a pick the gate accepts moves the graph's cursor — the same cursor a declared route edge moves. That is what makes the read-the-menu fallback real: when no entry rule matches the user's phrasing, the model picks a skill, the skill loads on the next iteration with its body and tools, and the graph's own steps run from there.

One rule decides a tie. If a declared edge fires on the same turn (the model emitted a domain tool and read_skill in one message), the declared edge wins — an author's deterministic route is never overridden by a model guess — and the run emits agentfootprint.skill.reroute_superseded with { volunteeredId, wonId, fromSkillId, iteration } so the pick that was dropped is on the record rather than silently forgotten.

Parallel tool batches route on EVERY call, in call order (9.16.0)

When the model calls several tools in one message, every result of that batch drives routing — evaluated in call order. Per result, edges are tried in declaration order (the same tiebreak as always); across results, the first one that matches an edge wins the cursor move. Before 9.16.0 only the last call of a batch was consulted, so the same two calls routed differently depending on their order in the message.

If two results of one batch match edges to different targets, the first still wins — and the run emits agentfootprint.skill.route_conflict with { winner, losers: [{ toolCallId, toolName, target }] }, so the hop the run did not take is on the record rather than silently dropped. Same-target matches are not a conflict, and a single-tool iteration behaves exactly as before.

agent.on('agentfootprint.skill.route_conflict', (e) => {
  console.warn(
    `batch conflict on iteration ${e.payload.iteration}: ` +
      `${e.payload.winner.toolName} → ${e.payload.winner.target} won; ` +
      e.payload.losers.map((l) => `${l.toolName} → ${l.target}`).join(', ') +
      ' suppressed',
  );
});

Rule predicates that want the whole batch can read ctx.toolResults (call order, toolCallId included) — ctx.lastToolResult remains the last entry.

Before 8.3.0 an accepted pick was reported to the model as activated and then ignored for any skill whose activation was cursor-gated or rule-gated — including the case where a start: { rules } graph matched no rule and therefore could never load a skill at all. See the CHANGELOG for the full shape of that fix.

Skills the graph doesn't route are open (8.4.0)

The gate bounds where the graph's cursor can go; it does not own the agent's whole skill catalog. A skill the graph never wires — no entry, no steps edge, no tree leaf — is open: read_skill reaches it from any cursor, it activates through its own llm-activated trigger, and it does not move the cursor (a skill the graph does not route is not a node, so it cannot be a hop). That is what lets .selfExplain(), a .skill() registered beside the graph, and a skill listed in skills[] but wired to nothing all work under a graph; before 8.4.0 every one of them was offered in read_skill's menu and then refused on every call. What the graph does wire stays bounded — including a bare model edge .route(a, m), which is still reachable only from a.

Declaring the graph — the object form is the canonical spelling

You declare a graph as one object literal: the skills, where a turn starts, and the tool-result transitions between them. This is the form the docs teach everywhere (a fluent builder also exists — see Advanced: the fluent builder — and compiles to the identical graph). New to skill graphs? The 5-minute quickstart builds one end to end and draws it.

import { skillGraph } from 'agentfootprint/context';

const graph = skillGraph({
  skills: [triage, billing, tech],
  start: 'triage', // or { rules: [...] } — see "Start rules as data" below
  steps: [
    { from: 'triage', to: 'billing', when: (r) => /refund|charge/i.test(String(r.result)) },
    { from: 'triage', to: 'tech', onToolReturn: 'run_diagnostic' },
  ],
});

skillGraph({ ... }) takes a SkillGraphConfig, which is a union of two arms that cannot be mixed: SkillGraphFlatConfig (start + steps — where start is a SkillGraphStart and each step is a SkillGraphStep) and SkillGraphTreeConfig (a tree, which owns the routing outright, so it has no entry menu and no cursor for start/steps to describe). Passing both is a type error and a build-time refusal, because only one of them ever compiled: before 8.4.0 the tree won and the flat wiring — plus every listed skill that was not a leaf — was discarded in silence.

Checking the graph before you run it — graph.checkup()

graph.checkup() inspects the declared graph like a spell-checker and returns { ok, problems }. Only unknown-skill and no-entry are errors; everything else is a warning, because a graph the model can still navigate with read_skill is not a broken graph. Since 8.7.0 it also reports multi-entry-fanout (two or more entries and no .entryBy() / .entryByRead() to choose between them, so every matching entry loads while only one can be the cursor), dead-entry-step (an entry declared after an unconditional one can never be the cold-start cursor, so the routes out of it never fire from there) and model-edge-only (the only way in is a bare step { from, to } with no when/onToolReturn, which compiles to no trigger at all — the model has to ask, and only from from). Start rules declared as data add three more codes — rule-id-exists, overlapping-rules and rules-shadowed-by-order — covered in Start rules as data below. Declaring examples on a rule adds four more again — example-misses-own-rule, example-shadowed-by-earlier, example-shadowed-by-default and example-unclaimed — covered in Examples on a start rule.

A report can also carry notes: statements about what the report does not cover. They are present only when a check ran whose silence could be misread as proof (today, the example checks), and formatCheckup prints them after the problems, tagged [note].

Pass CheckupOptions — today, knownTools — to tell it about the tools the AGENT registers with .tool(). A graph knows only the tools its own skills carry, so without this a body saying lookup_order(id) reads as naming a tool that exists nowhere. checkSkillContract and checkSkillContracts take the same SkillContractOptions. formatCheckup(report) renders a report the way the library's own build-time refusal does, which is what to print in CI:

import { formatCheckup } from 'agentfootprint/context';

const report = graph.checkup({ knownTools: ['lookup_order'] });
if (!report.ok) throw new Error(formatCheckup(report));

.build() runs the same check, and since 8.7.0 defaults to check: 'throw' for both the fluent and the object form — a graph with no entry cannot start a turn, and used to build in silence. check: 'warn' still never throws.

Start rules as data — match beside when

A start rule can declare its condition as data instead of code: match takes a SkillMatch — a RegExp tested against the user's message, { keywords: [...] } (case-insensitive, any keyword present, whole-word at word edges, so refund never fires on "refunds"), or { all: [...] } — the conjunction (9.20.0): the rule matches only when every member matches. Each rule takes exactly one of match/when — both or neither is a build-time refusal naming the rule — and the rule's type is SkillStartRule, so TypeScript refuses the same pair at the keystroke.

const graph = skillGraph({
  skills: [zoneAudit, refunds, billing, triage],
  start: {
    rules: [
      // "zone AND audit-shaped" as data, not a lookahead regex. Specific-first:
      // the conjunction sits above the broader rules it composes from.
      { match: { all: [/zone/i, { keywords: ['audit', 'sweep', 'all'] }] }, use: 'zone-audit' },
      { match: /refund|money back/i, use: 'refunds' },
      { match: { keywords: ['charge', 'invoice'] }, use: 'billing' },
      { when: (ctx) => ctx.iteration > 1, use: 'triage' }, // predicates still work beside data
    ],
  },
});

all holds the sync arms only (RegExp / { keywords }). An intent member is refused at build — intents are judged by the graph's classifier at turn start and cannot compose into a synchronous AND; the refusal names the alternative (declare the intent as its own rule). A nested all is flattened (AND is associative, so the flattened form runs identically and the stored data describes exactly what runs), and an empty all is refused. toMermaid() captions the entry edge with the parts joined by AND.

Why data? A predicate is opaque — the library can only run it. A matcher can be compared, drawn and stored: toMermaid() captions the entry edge with the pattern, the compiled skill's provenance (metadata.skillGraph.match) and the entry edge carry a serializable SkillMatchData ({ kind: 'regex', source, flags }, { kind: 'keywords', keywords } or { kind: 'all', parts } — parts always the flat leaves), and the check-up gains three codes — one for every rule, two only possible over data: rule-id-exists (ERROR — a rule routing to a skill that is not in skills[] refuses to build under every check mode, listing every bad id and the known catalog), overlapping-rules (two data matchers provably overlap — a shared keyword — and declaration order decides those messages) and rules-shadowed-by-order (a later rule provably can never win: an identical regex earlier, or an earlier keyword set that is a superset of the later one). The comparisons claim only what the data proves — when predicates are opaque and the messages say they were not checked. A conjunction is compared through part coverage, shadows-only: an all rule matches a subset of each of its parts' messages, so a plain rule earlier that covers one part shadows a later all rule (a dead rule, warned), while the reverse layout — the all rule first, its broad fallback later — is the intended specific-first design and stays silent; no overlap is ever claimed for a pair involving all (a conjunction can be unsatisfiable, so no witness message is provable). And a router where every entry carries a when or a match is a supported design: it never trips multi-entry-fanout.

Examples on a start rule — the phrasings it claims

examples is an optional list of real phrasings a rule says it claims. It is test material: read at build time by the check-up, fed to nothing at run time. A rule with examples routes byte-identically to the same rule without them.

const graph = skillGraph({
  skills: [powerstoreHealth, arrayInventory],
  start: {
    rules: [
      {
        use: 'powerstore-health',
        match: /\bpowerstore\b|\bshpstr[a-z]*\d+\b/i,
        examples: ['is powerstore healthy'],
      },
      {
        use: 'array-inventory',
        match: /\b(array|volume|pool|shpstrprncl\d+)\b/i,
        examples: ["what's running on shpstrprncl101"],
      },
    ],
  },
});

Why. Two failures from one production deployment, in one evening — neither of them catchable by comparing matchers:

  • Shadowing by different regexes. A rule matching one product name sat above an inventory rule whose matcher is a longer alternation. A real phrase — "what's running on shpstrprncl101", an array named like one of that product's boxes — was claimed by the earlier rule, so the later rule could never win on its own phrasings. compareMatchers deliberately answers "not decided" for two different regex sources (intersection is never decided in this library — only identity is provable), so the check-up was honestly silent. Correct behavior, real gap.
  • Absence. A second phrase matched no rule at all, fell through to the model tier, and the model picked the wrong skill because an array name looked like a hostname. Not shadowing — absence. No matcher-vs-matcher analysis can ever catch it.

A declared phrase turns both into arithmetic. The check-up runs the compiled predicates over the phrase in declaration order, exactly as the cold start does, and reports a witness rather than a theory:

codeseveritywhat it proves
example-misses-own-ruleerror / warningThe rule does not claim its own example. An error when the rule is a data match (a data matcher reads the user message and nothing else, so the no-match holds under every context) or when the predicate threw; a warning when an opaque when merely returned false — see the context every predicate is judged on below.
example-shadowed-by-earliererrorAn earlier conditional rule claims the phrase first, so the turn starts where the author's own example denies. Names N, M and the phrase. This is the case rules-shadowed-by-order must stay silent about.
example-shadowed-by-defaultwarningThe earlier claimant is an unconditional entry — the one place the two start laws differ, and which law applies is decided at agent mount. The message names both readings instead of asserting one.
example-unclaimedwarningNo rule claims the phrase on that context, so the turn falls through to the model tier (or, with a classifier, past tier 1 to it). A warning: the run is not broken, the next tier may still answer well — what is proven is that nothing declared claims it.

Where a graph's own examples are the corpus, example-unclaimed proves coverage, not merely non-overlap.

The context every predicate is judged on

The check runs each condition on exactly one context: iteration 1, the phrase as userMessage, empty history, no cursor and no activated injections — the context a turn's first iteration really hands a start rule. Every message says so, because a check that hides its inputs can only be guessed at.

That context is real but not the only one. Turn 2 of a conversation also starts cold in cursor terms while carrying history, so a when gated on conversation state — ctx.history.length > 0 && /refund/i.test(ctx.userMessage) — legitimately claims the phrase on a later turn and cannot claim it here. That is why severity follows provability: a data match reads the user message and nothing else (error), an opaque when might be right on a turn this check cannot run (warning). A predicate that throws stays an error either way — turn 1 really hands it that context, so it throws in production too.

Two start laws, and why the check-up will not pick one

Which entry claims a phrase is decided by one of two laws:

  • the declaration-order cold start (the default mount, continuity: 'turn') returns at the first entry with no condition, or the first whose condition passes — so an unconditional entry claims every message from its position onward;
  • the turn-start cascade's tier 1 reads the conditional entries only — an unconditional entry is a default, not a rule, and never wins the turn there. That cascade is mounted by .classify() and by .skillGraph(graph, { continuity: 'conversation' }).

They differ in exactly one place: whether an unconditional entry claims. continuity is an agent-mount option that does not exist yet when the graph is built, so when the earlier claimant is unconditional the report warns (example-shadowed-by-default) and names both readings; the error is kept for what both laws agree on. Asserting one would put the check-up in disagreement with the router — under continuity: 'conversation' the turn really does start on the later rule.

The tier difference, and it matters. Both spellings mean "the phrasings this rule claims", and their runtime roles are opposites:

whererolewhen it is read
match: { intent, examples } (tier 2)scoring material — the classifier judges new messages against themevery turn, at run time
examples: [...] beside match/when (tier 1)test material — the check-up runs the matcher over thembuild time only, never at run time

Tier-1 examples do not widen matching. Nothing is fed to any scorer, and no message is matched against them. A rule may not carry both lists: match: { intent, examples } plus a rule-level examples is a build-time refusal that names the two jobs.

Every unusable declaration is refused where it is written, naming the fix: an empty list (nothing to prove — and a check-up that passes in silence reads as coverage), a blank or non-string entry, and examples on an unconditional entry (it claims every message, so every example passes by construction).

The boundary, stated by the check-up itself. These checks prove things about the phrases you declared and nothing about phrases nobody wrote — no warning is not proof of coverage. That sentence rides graph.checkup().notes, so a clean report carries it too:

const report = graph.checkup();
report.notes;
// [ 'These example checks prove things about the phrases you DECLARED and nothing
//    about phrases nobody wrote — no warning here is not proof of coverage.' ]

Under .entryBy() / .entryByRead(), declaration order does not decide the turn start, so only the order-independent check (self-match) runs — and a note says which checks were skipped rather than leaving the silence to be read as a pass.

Scoping tools on the flat arm — scopeTools

A decision .tree() has stamped autoActivate: 'currentSkill' on its leaves since 8.7.0. The flat arm now takes the same dial: skillGraph({ skills, start, steps, scopeTools: true }) (fluent: .build({ scopeTools: true })) stamps every wired skill — everything an entry or a step mentions — so a skill's tools reach the LLM only while the graph is on it, instead of every skill's tools landing in the always-on registry from iteration 1. The default is false (today's behavior, byte-identical builds) until 10.0.0 flips it. A skill whose author set autoActivate explicitly always keeps its own setting — the graph level fills a default, it never overrides — and a listed-but-unwired skill is not stamped (the graph does not route it, so it does not scope it).

const graph = skillGraph({
  skills: [refunds, billing, triage],
  start: { rules: [{ match: /refund/i, use: 'refunds' }, { when: () => true, use: 'triage' }] },
  scopeTools: true, // wired skills get autoActivate: 'currentSkill' — tools follow the cursor
});

The 5-minute quickstart shows the effect end to end; autoActivate itself is covered in Per-skill tool gating.

Body-contract checks that wait for the agent — DeferredBodyContract

A graph built without knownTools cannot tell a body's lokup_order( typo from a baseline tool the agent registers later — the graph builds before .tool() runs. So instead of warning about what it cannot prove, .build() now defers the two body-contract checks (body-foreign-tool / body-unknown-tool) and stamps a DeferredBodyContract note (carrying the graph's check mode) in two places that say the same thing: on the graph (graph.deferredBodyContract) and on each compiled skill's own metadata. The per-skill stamp is what makes the note travel: agent build collects it from the final injection list and runs those checks exactly once, against the union of the agent's registered tools, read_skill, every checked skill's own tools and any non-graph skill's tools — whichever way the skills arrived, .skillGraph(graph) or .skills({ list: () => graph.skills }). One problem is reported at one build point, never both: a graph built with an explicit knownTools runs its checks at graph build (the manual override stays an override) and carries no note, and a skill found through both the metadata and the graph note is deduped by id. check: 'off' stays off at both points, and graph.checkup() is unchanged — the explicit lint call always runs every check over what it can see. A graph that never reaches an agent build defers to nobody by itself — lint it with graph.checkup() (passing knownTools for the baseline names) if it will be consumed outside an Agent.

// orders' body says "call lookup_order(id)" — a tool the AGENT registers, not the skill.
const graph = skillGraph({ skills: [orders], start: 'orders' }); // no knownTools
graph.deferredBodyContract; // { mode: 'throw' } — the note the agent build reads

const agent = Agent.create({ provider, model })
  .tool(lookupOrderTool) // the baseline tool the graph could not see
  .skillGraph(graph) // deferred checks run at .build(), against the full registry
  .build(); // lookup_order resolves; a `lokup_order(` typo would be reported here

// .skills({ list: () => graph.skills }) is served identically — the note rides
// each compiled skill's metadata, so the door that never sees the graph object
// still runs the deferred checks at .build().

The whole batch — data matchers, the rule check-up codes, scopeTools, deferred body-contract checks — runs end to end in examples/features/54-skill-graph-front-door.ts.

Where the graph stands, and why it resets — CursorMove

graph.explainNextSkill(ctx) returns a CursorMove: where the cursor landed and, in by, the CursorMoveCause that decided it — 'entry', 'route', 'model-pick', 'stay' or 'none'. That is what lets routeRecorder() tell a model's read_skill pick from a declared edge that happens to point at the same skill.

The cursor is per run. It tracks position across the iterations of ONE agent.run(); a second run starts cold, at the entry, whatever the first ended on. A skill graph declares how one turn is routed, so a cursor that survived would make turn two start somewhere nobody declared. To carry a position between turns, persist the id yourself and start the next turn's graph from it.

When a hint cannot fire

defineRelevanceHint() reads ctx.entryScores, which only an entry SCORER (.entryBy() / .entryByRelevance()) ever writes. Mounted on a graph without one it can never activate, so in dev mode the agent build now says so — keyed on the READS_ENTRY_SCORES_METADATA_KEY marker the factory stamps, so a renamed hint is caught too.

Advanced: the fluent builder

skillGraph() called with no argument returns a fluent builder — the original spelling, fully supported and not deprecated. It compiles to the identical graph as the object form (same nodes, same edges, same triggers), so choosing between them is taste, not capability. The docs teach the object form; reach for the builder when you are migrating older code or assembling a graph programmatically, method by method.

// The fluent spelling of the graph from "Declaring the graph" above:
const graph = skillGraph()
  .entry(triage)
  .route(triage, billing, { when: (r) => /refund|charge/i.test(String(r.result)) })
  .route(triage, tech, { onToolReturn: 'run_diagnostic' })
  .build({ check: 'throw' });

The mapping is one-to-one:

object formfluent builder
skills: [...]implied — every skill a method mentions is remembered
start: 'id' / { use }.entry(skill)
start: { rules: [{ match | when, use }] }.entry(skill, { match }) / .entry(skill, { when })
start: { entries, scoredBy / byRelevance }.entry(...) × n + .entryBy(scorer) / .entryByRelevance(embedder)
steps: [{ from, to, when / onToolReturn / onToolStatus }].route(from, to, { when }) / .route(from, to, { onToolReturn }) / .route(from, to, { onToolStatus })
tree: root.tree(root)
check / knownTools / scopeToolsthe same fields on .build({ ... })

Every law on this page applies to both spellings identically — the union arms, the check-up, match as data, scopeTools, deferred body-contract checks — because the object form is compiled through the builder: there is one implementation.

Per-provider surface selection (surfaceMode)

import { defineSkill, resolveSurfaceMode } from 'agentfootprint/context';

const billingSkill = defineSkill({
  id: 'billing',
  description: 'Refund / charge / billing.',
  body: 'When handling billing: confirm the order id...',
  tools: [refundTool],
  surfaceMode: 'auto',  // resolves to 'both' on Claude ≥ 3.5; 'tool-only' elsewhere
});

// Pure inspector — see what 'auto' will resolve to in your stack:
resolveSurfaceMode('anthropic', 'claude-sonnet-4-5-20250929');  // → 'both'
resolveSurfaceMode('openai', 'gpt-4o');                         // → 'tool-only'

Four modes: 'system-prompt', 'tool-only', 'both', 'auto'. See Skills, explained for the full per-provider attention argument.

Per-mode runtime dispatch

What each mode actually does at runtime when the LLM activates the skill via read_skill('id'):

surfaceModeSystem slot (next iteration)read_skill tool result
'system-prompt'body lands hereconfirmation only
'tool-only'body SUPPRESSEDbody delivered verbatim
'both'body lands herebody delivered verbatim
'auto' (default)body lands hereconfirmation only

Two consequences worth knowing:

  1. 'tool-only' is recency-first by protocol. The LLM sees the body as the most recent tool result on the next iteration — providers' attention to the latest message is consistently strong. No reliance on system-prompt training adherence.
  2. 'auto' keeps the body in the system slot so existing consumers see no surprises. To get provider-aware resolution (Claude ≥ 3.5 → 'both'; everything else → 'tool-only'), call resolveSurfaceMode(provider, model) yourself and pass the concrete mode, or set a registry-level default via new SkillRegistry({ surfaceMode }).

'tool-only' requires that read_skill is what activates the skill (8.5.0)

Look at the table again: 'tool-only' means "the body is suppressed from the system slot and delivered as the read_skill tool result." That channel only exists when the model calls read_skill.

A skill a skill graph activates never gets that call. A route target, a graph entry and a decision-tree leaf all activate off the graph's cursor — so the tool result never happens, the system slot suppresses the body anyway, and the body reaches the model through no channel at all. Its tools still arrive, which is worse than the skill not loading: the model is handed the tools of a procedure nobody described.

The agent now refuses that combination at build time, naming every offending skill and how it is routed:

Agent: This skill sets surfaceMode: 'tool-only', which delivers the body as the
read_skill tool result — but nothing here activates by read_skill, so its body would
reach the model NOWHERE: the system slot suppresses a tool-only body by design, and
no read_skill call happens to carry it.
  • "beta" — a route target (the graph routes "alpha" → "beta")
Use 'both' (system prompt AND tool result) or 'system-prompt'. Only a skill read_skill
actually activates — trigger 'llm-activated', which is every skill a graph does not
route — can be 'tool-only'.

'both' is the fix when a skill is reachable both ways: the body lands in the system slot on a cursor hop and in the tool result on a read_skill pick.

Unaffected: a .skill() registered outside a graph, a bare model edge target (.route(a, m) with no when/onToolReturn), and any open skill — all keep the llm-activated trigger, so read_skill really is what activates them.

Steps as data (steps, 9.18.0)

A skill's body can describe a procedure, but prose decays with context length and the model can call any tool at any time — the order was a hope, not a mechanism. steps declares the procedure as data, and the framework enforces it at the protocol level: while the skill is the active tenure, the tools slot sends only the current step's tool (its description led by [Step k of n — <note>]) plus a skip_step tool. A schema that was never sent cannot be called — the sequence is owned with no refusal machinery at all.

const refundProcedure = defineSkill({  id: 'refund',  description: 'Handles refunds end to end, by declared procedure.',  body: 'Follow the refund procedure. Every step says why it exists.',  tools: [    t('find_order', 'order #4021: $42.10, card ending 4242'),    t('check_history', 'one charge on 2026-08-01, one duplicate on 2026-08-02'),    t('verify_identity', 'identity verified'),    approveRefund,    t('issue_refund', 'refund of $42.10 issued'),    t('file_receipt', 'receipt R-991 filed'),  ] as never,  steps: [    { tool: 'find_order', note: 'find the order before touching money' },    { tool: 'check_history', note: 'confirm the duplicate charge' },    { tool: 'verify_identity', note: 'verify the caller owns the card' },    { tool: 'approve_refund', note: 'a person approves before money moves' },    { tool: 'issue_refund', note: 'refund the duplicate charge only' },    { tool: 'file_receipt', note: 'file the receipt for audit' },  ],  // 'advance' (the default, spelled out): a recorded skip moves on.  onSkip: 'advance',});

The framework owns sequence and scope; the model owns judgment inside the step:

  • run the tool — the result gains a fresh position line ("Step 2 of 6 done. Now on step 3 of 6: …"), so the guidance that matters now never decays out of attention;
  • skip itskip_step(reason) puts the decline on the record (agentfootprint.skill.step_skipped) and the declared onSkip decides: 'advance' (default) moves on, 'hold' keeps the step current;
  • use an escape hatchread_skill, list_skills, every other active skill's tools, the baseline .tool() registry and provider tools all stay offered under narrowing. Only tools that the stepped skill alone brought are held back;
  • or stop and say why — a final answer with steps unrun gets one teaching nudge per turn (steps_unfinished { action: 'nudged' }, an ordinary loop turn); stopping again is honored ('accepted'), and a limit ending the turn is honored too ('cut-short'). A procedure is a declared order, not a cage.

A step whose tool asks a human (askHuman / pauseHere) pauses the run mid-procedure; the pointer rides the checkpoint and the person's answer advances the step on resume — human-in-the-loop as an ordinary step:

// The run pauses at step 4 — approve_refund asked a human.const outcome = await agent.run({ message: input });let answer: string;if (isPaused(outcome)) {  record.push('(paused at the approval step — a person answers…)');  // The person's answer IS the step's result; the step advances on resume.  const resumed = await agent.resume(outcome.checkpoint, 'approved — refund the duplicate');  answer = isPaused(resumed) ? '(paused again — not expected in this script)' : resumed;} else {  answer = outcome;}

Rules worth knowing: every step must name one of the skill's own tools (refused at defineSkill otherwise; repeats are legal); steps are turn-scoped — a graph cursor move away resets the pointer, and under continuity: 'conversation' only the cursor carries, so a re-tenured skill starts at step 1; reactMode: 'classic' cannot honor steps (the cached tools slot would freeze the offer — refused at build); and on a graph agent a stepped skill must be wired into a flat graph (an entry or a route target). An open skill's tenure never begins, and a decision .tree() never writes a cursor at all — so steps on an unwired skill and steps on any skill of a tree agent (leaf or registered beside it) are both refused at build. Read the live position off a snapshot with pointerOf(sharedState.stepPointer) from agentfootprint/injection-engine.

One honest cost note: the banner changes the tools block at every step boundary, so a tools-slot prompt-cache marker invalidates once per step — the CacheGate's existing churn rules govern, exactly as they do for autoActivate tool swaps.

The steps surface, named

Everything steps-related imports from agentfootprint/injection-engine (also reachable through its agentfootprint/context alias):

  • SkillStep — one declared step: the tool it runs (one of the skill's own tools) and the note the model reads.
  • OnSkipPolicy — the 'advance' | 'hold' union behind onSkip: what the framework does when the model skips a step.
  • StepPointer — where a procedure stands: skillId (the tenant), a 1-based step, total, and the skipped indexes the pointer moved past; step === total + 1 means the procedure completed.
  • StepPointerCarrier — how the pointer travels through scope and every mount mapper: a 0-or-1-element readonly array; pointerOf(...) unwraps it back to a StepPointer.
  • StepPlan — the frozen build-time fold of one stepped skill: its steps, the hold-out candidate toolNames, and its onSkip policy.
  • buildSkipStepTool — builds the skip_step integrity tool for custom wiring; SKIP_STEP_TOOL_NAME is its reserved name ('skip_step').
  • defineStepsHint — the advisory injection auto-registered for stepped agents, telling the model a declared procedure is in progress; StepsHintOptions overrides its id or body, and STEPS_HINT_METADATA_KEY is the metadata marker a custom hint carries so the auto-registered default stands down.

On the event stream: agentfootprint.skill.step_advanced fires at every completed step, in batch call order (the last one carries completed: true); agentfootprint.skill.step_skipped records a decline with the model's own reason and the declared policy; and agentfootprint.skill.steps_unfinished records how a turn ended with steps unrun — 'nudged', 'accepted', or 'cut-short'.

Artifact vocabularies (produces / consumes, 9.25.0)

steps declares the procedure as data. Vocabularies declare the data legs: which artifact kinds a skill leaves behind and which it needs to have arrived. Two programs meet here — the artifact store gave payloads a consumer vocabulary (kind: 'dataset/rows', 'chart/spec'), and the skill graph gave a run a declared shape; neither could see the other, so a skill whose whole job was turning a dataset into a chart said so only in prose.

defineSkill({
  id: 'charting',
  description: 'turn a dataset into a chart spec',
  body: 'Render the dataset you were given.',
  consumes: ['dataset/rows'],       // what must have arrived
  produces: ['chart/spec'],         // what it leaves behind
  tools: [fetchRows, renderChart],
  steps: [
    { tool: 'fetch_rows',    note: 'pull the rows first', produces: ['dataset/rows'] },
    { tool: 'render_chart',  note: 'then draw them',      consumes: ['dataset/rows'] },
  ],
});

A step may declare them too (SkillStep.produces / SkillStep.consumes), which is what makes a single skill's internal hand-off checkable.

These are declarations, not machinery. Nothing at run time reads them, nothing is enforced at dispatch — that is wants' job, and it happens against the live store. A skill that declares none is byte-identical to one that never heard of them: the metadata bag grows no keys, and the check returns immediately.

What gets checked, and what it cannot see

graph.checkup() reports artifact-kind-unsatisfied — always a warning, never an error. A consumed kind K is satisfied when any of these holds:

#satisfierapplies to
1a tool in scope declares wants for K — the redemption path the framework honors at dispatchskill (any of its tools) · step (the tool that step runs)
2anything on the agent declares produces: K — any skill, or any step of any skillskill · step
3an earlier step of the same skill produces K, or the skill's own consumes names K (it arrived from outside)step only

The boundary, stated because a check that overclaims is worse than none. It reads declarations only. A tool that mints an artifact without declaring produces is invisible to it; so is anything the store received in an earlier run, from another agent, or from a job outside this process — artifacts outlive the turn that made them. It also does not follow route order: a producer declared anywhere on the agent silences it, because read_skill can put the cursor on any open skill. The only thing it proves is the strong one: nothing on this agent claims to make what this consumer claims to need. That is exactly why it warns instead of erroring, and the warning says so in its own message.

If the kind really does arrive from outside, declare a tool that wants it — that is the honest escape hatch, and it silences the check because it is a real redemption path.

Declaring consumes on the skill to satisfy a step does not make a gap disappear; it moves it up a level, to the skill, where it can actually be answered.

For your own tooling, the check is public: checkArtifactVocabularies(skills) runs the rule over any list of skills, and vocabularyOf(skill) reads a skill's declared ArtifactVocabulary back off its metadata — undefined when it declared none, so a lens can skip the whole feature.

Per-skill brains — model switching (9.19.0)

A skill graph already decides where the run is (the one cursor); a brain lets that same position decide who answers. Declare it beside the skill — defineSkill({ provider, model }) — or for a whole fleet at the mount — skillGraph(g, { providers: { refund: { provider, model } } }). While the cursor is on that skill, every LLM call runs on its brain: triage on the small model, the refund skill on the strong one, with zero new stages.

// The refund skill declares its own brain: while the cursor is on it,// every LLM call runs on THIS provider/model instead of the agent's.// (Same-name providers may omit `model` and inherit down the chain; a// FOREIGN provider must name one — refused at build otherwise.)const refundBrain = provider ?? refundBrainScript();const refund = defineSkill({  id: 'refund',  description: 'Handles refunds: look up the order, then issue or deny.',  body: 'Check the order with issue_refund after the account lookup routed you here.',  tools: [issueRefund] as never,  provider: refundBrain,  model: 'refund-strong-model',});const triage = defineSkill({  id: 'triage',  description: 'First contact: look up the account.',  body: 'Start every request with check_account.',  tools: [checkAccount] as never,});const escalation = defineSkill({  id: 'escalation-desk',  description: 'Handles denied refunds with the playbook.',  body: 'The refund was denied. Follow the delivered playbook.',});const graph = skillGraph()  .entry(triage)  .route(triage, refund, { onToolReturn: 'check_account' })  // Route on MEANING: only a DENIED issue_refund moves to the desk.  .route(refund, escalation, { onToolReturn: 'issue_refund', onToolStatus: 'denied' })  .build();

The precedence chain, stated: escalation > per-skill brain > .configure() resolvedModel > build-time default — the more specific context wins (evidence > node > run > build), field by field. A brain naming only a model keeps the agent's provider ("same brain, other model"); a brain naming only a same-name provider inherits the model down the chain; a foreign provider without a model is refused at build — the agent's model id belongs to another vendor's namespace and would fail mid-turn, on exactly the iteration the cursor first enters the skill. The same id declared in both homes with different choices is refused naming both. llm_start records the winner: brain: { via: 'skill' | 'escalation', skillId? } — absent whenever the agent's own configuration answered, so an agent without brains keeps its exact prior event bytes.

Escalate on evidence. skillGraph(g, { escalation: { provider, model, afterRefusals: N } }) — when the routing gate refuses N picks in one turn (skill.rejected, reachability or posture — recorded refusals, never vibes), the rest of the turn runs on the escalation brain: skill.escalated fires once at the flip with the honest from/to, and the next turn's seed de-escalates. The loop the model is flubbing gets the bigger brain until the turn resolves.

The tier-3 decider. skillGraph(g, { decider: { provider, model } }) resolves an outstanding turn-start menu out of band, before the loop — a constrained-enum pick over the offered ids ∪ stay (the llmClassifier machinery: forced tool where the provider constrains generation, strict parse + one re-ask + 'none' everywhere else; ids never fabricated). A decisive pick starts the turn (turn_routed { by: 'decider' }); 'stay' holds the incumbent and closes the menu (the event keeps the full offered set; the loop may no longer act on it); a decline leaves the in-band envelope standing, consult recorded. Because the decider is constrained, off-loop, and recorded, rails admits it — it is the sanctioned resolver for strictness: 'rails' menus, which otherwise proceed on the base prompt. A decider needs a graph that runs the turn-start cascade (a classifier, or continuity: 'conversation') — refused at build otherwise, because no other mount ever has a menu for it to resolve.

Steps × brains are independent by construction: a stepped skill with a brain runs its procedure on its brain — the cursor picks both the working set and the reader of it.

The brains surface, named

From agentfootprint:

  • ProviderChoice — one brain: an LLMProvider port plus an optional model. The value type of SkillGraphOptions.providers and the decider field's shape.
  • EscalationPolicy — a ProviderChoice plus afterRefusals, the recorded-refusal threshold that flips the turn.

On the event stream: agentfootprint.skill.escalated fires once at the flip (declared threshold, observed refusals, the honest from/to), and agentfootprint.stream.llm_start gains the additive brain field naming the rung that answered.

From agentfootprint/context, the decider's machinery — shared with llmClassifier so the two enum disciplines can never drift:

  • constrainedEnumPick — ONE model call that can only answer from a fixed list: a forced synthetic tool on providers that declare carriesForcedToolChoice, a strict single-line parse + one re-ask + the caller's fallback everywhere else.
  • ConstrainedEnumPickRequest — its request bag (provider, model, catalog prompt, messages, the allowed enum, fallback, the pick tool).
  • EnumPickTool — the synthetic pick tool's name/description/argument, as the wire shows them.

Long-context refresh (refreshPolicy)

defineSkill({
  id: 'critical-rule',
  description: 'Critical reasoning rule for long-context runs',
  body: 'When the value is ambiguous, ask for clarification before acting.',
  refreshPolicy: { afterTokens: 50_000, via: 'tool-result' },
});

DEPRECATED (9.16.0), superseded by steps. The field is stored on skill.metadata.refreshPolicy and nothing in the engine has ever read it — no re-injection happens, on any version. Since 9.18.0 the steps feature delivers what this field promised, by construction: the step banner is re-sent in the tool description on every request, and every boundary result carries the fresh position line — recency that cannot decay. The field stays accepted so existing declarations keep compiling (dev mode warns once per process when one is set, and once more at Agent.build() when it rides beside steps), and it will be removed in the next major. To re-surface a body without steps, use surfaceMode: 'both' — every read_skill call returns the body afresh, recency-first.

SkillRegistry — centralized governance

For shared skill catalogs across multiple agents:

import { Agent } from 'agentfootprint'
import { SkillRegistry } from 'agentfootprint/context';

const registry = new SkillRegistry();
registry.register(billingSkill).register(refundSkill).register(complianceSkill);

const supportAgent = Agent.create({ provider }).skills(registry).build();
const escalationAgent = Agent.create({ provider }).skills(registry).build();

// Add a skill — every consumer Agent picks it up at next build.
registry.register(newSkill);

agent.skills(registry) is the bulk-register companion to .skill(t). Use the registry pattern when 2+ agents share overlapping skills; use .skill(...) directly when one agent has its own catalog.

SkillRegistry methods: register(skill) · replace(id, skill) · unregister(id) · get(id) · has(id) · list() · clear() · size · toTools() · resolveForSkill(skillOrId, provider?, model?). Throws on duplicate register (use replace for explicit overwrites). Throws on non-Skill flavor inputs.

Registry-level defaults — new SkillRegistry({ surfaceMode, providerHint }) (v2.5)

When every skill in a registry should share the same surfaceMode, set it once on the constructor instead of repeating it on every defineSkill:

import { SkillRegistry } from 'agentfootprint/context';

// All skills here default to 'tool-only' (overrides defineSkill's 'auto')
const registry = new SkillRegistry({ surfaceMode: 'tool-only' });
registry.register(billingSkill);   // billingSkill.surfaceMode 'auto' → resolves to 'tool-only'
registry.register(refundSkill);

// providerHint helps when the registry is composed far from the agent
// (test fixtures, design-time inspectors, multi-provider routing).
const registry2 = new SkillRegistry({ providerHint: 'anthropic' });

The cascade for surfaceMode resolution is:

  1. Per-skill explicit surfaceMode wins. defineSkill({ surfaceMode: 'both' }) is honored regardless of registry default.
  2. Registry's surfaceMode ctor opt (if set + not 'auto').
  3. Global resolveSurfaceMode(provider, model) — Claude ≥ 3.5 → 'both', everything else → 'tool-only'.

Inspect the resolved mode for any registered skill:

registry.resolveForSkill('billing', 'anthropic', 'claude-sonnet-4-5');
// → returns 'system-prompt' | 'tool-only' | 'both' (never 'auto')

Per-mode routing is live at runtime — 'tool-only' suppresses the body from the system slot and delivers it via the read_skill tool result, 'both' does both, and 'system-prompt' / 'auto' keep the body in the system slot (see Per-mode runtime dispatch). resolveForSkill(...) lets you inspect the resolved mode at design time.

registry.toTools() — explicit composition (v2.5)

When you want to wire skill discovery into a custom tool chain (e.g., a gatedTools layer that filters by role) instead of the Agent's auto-attached read_skill, use toTools():

import { SkillRegistry } from 'agentfootprint/context';
import { gatedTools, staticTools } from 'agentfootprint/providers';
import { PermissionPolicy } from 'agentfootprint/security';

const registry = new SkillRegistry();
registry.register(billingSkill).register(refundSkill);

const { listSkills, readSkill } = registry.toTools();
// listSkills: Tool — no-arg discovery (LLM calls to enumerate skills)
// readSkill:  Tool — same as the auto-attached one (activation by id)

const policy = PermissionPolicy.fromRoles({...}, 'support');
const allTools = [listSkills!, readSkill!, lookupTool, refundTool];
const provider = gatedTools(staticTools(allTools), (n) => policy.isAllowed(n));

Two reasons to choose toTools() over the auto-attach:

  1. Token-efficient discovery — the auto-attached read_skill embeds the catalog in its description (every iteration's tool list pays the cost). list_skills lets the LLM browse on demand; read_skill's description can stay terse. For ~20+ skill registries, this matters.
  2. Permission gating — pass read_skill through gatedTools like any other tool, so a readonly role can see list_skills but not read_skill, or vice versa.

toTools() returns { listSkills: undefined, readSkill: undefined } for an empty registry — filter with .filter(Boolean) before adding to a tool list.

Per-skill tool gating — autoActivate

By default, a Skill's tools array is ADDED to the agent's tool registry at BUILD time — the LLM sees those tools from iteration 1, activated or not. With ~3 skills and ~5 tools each, that's fine. With 20 skills and 100+ tools, the LLM's choice space gets noisy — every iteration's tool list pays the cost.

autoActivate: 'currentSkill' narrows the choice space: when you set it, the skill's tools are EXCLUDED from the static tool list and surface to the LLM ONLY on iterations after the skill is activated by read_skill('id'). Skills WITHOUT autoActivate keep the additive behavior (their tools are always visible).

import { Agent } from 'agentfootprint'
import { defineSkill } from 'agentfootprint/context';

const billingSkill = defineSkill({
  id: 'billing',
  description: 'Billing assistance',
  body: '...',
  tools: [refundTool, chargeTool],
  autoActivate: 'currentSkill',
});

// The Agent reads skill.metadata.autoActivate and wires the gate for you —
// billing's tools stay hidden until the LLM calls read_skill('billing').
const agent = Agent.create({ provider })
  .tool(lookupOrderTool)   // always-visible baseline
  .skill(billingSkill)
  .skill(refundSkill)      // also autoActivate: 'currentSkill'
  .build();

For a tool chain you compose yourself (outside the Agent's auto-attach — e.g., a gatedTools permission layer), materialize the same gate with skillScopedTools(id, tools), reading ctx.activeSkillId per iteration:

import { skillScopedTools, staticTools, type ToolProvider } from 'agentfootprint/providers';

const baseline = staticTools([lookupOrderTool, listSkills, readSkill]);
const billingScope = skillScopedTools('billing', [refundTool, chargeTool]);
const refundScope = skillScopedTools('refund', [reverseTool]);

const provider: ToolProvider = {
  id: 'composite',
  list: (ctx) => [
    ...baseline.list(ctx),
    ...billingScope.list(ctx),
    ...refundScope.list(ctx),
  ],
};

What the LLM sees per iteration:

ctx.activeSkillIdVisible tools
undefined (no skill)lookup_order, list_skills, read_skill
'billing'baseline + refund, charge
'refund'baseline + reverse

This is a Dynamic ReAct payoff: the next iteration's tool list reshapes based on what just happened. 3× context-budget reduction in large catalogs + sharper LLM tool-choice.

The autoActivate field is also stored on skill.metadata.autoActivate, so custom ToolProvider chains can read it to drive their own composition.

Skills authored as files — skillsFromDir

A skill body is prose. It is a playbook, a policy, a checklist — the kind of text a support lead should be able to edit, and a reviewer should be able to read as a diff. Kept in a template literal three imports deep, it gets neither: changing the refund policy becomes a code change, and nobody outside the codebase can see what the agent was told.

skillsFromDir(dir) reads a directory of SKILL.md files and hands each one to defineSkill. It is a loader, not a second mechanism — the frontmatter description is still all the model sees until it calls read_skill(<id>), and the body still arrives only after it does. Everything above on this page applies unchanged.

skills/
  billing/SKILL.md
  shipping/SKILL.md
---
name: billing
description: Use for refunds, disputed charges, invoices and any billing question.
---

Before anything else, confirm the customer's identity: ask for the order id and
the last four digits of the card on file.

Never quote a refund timeline shorter than three business days.
// One call. Every SKILL.md under the directory becomes a Skill.const skills = await skillsFromDir(skillsDir);
const agent = Agent.create({ provider, model })
  .system('You are a support agent. Open a skill when one applies.')
  .skills({ list: () => skills })
  .build();

That is the same file convention Claude Code made familiar, so a skill folder is portable between the two. Two layouts are accepted and can be mixed: dir/<anything>/SKILL.md (prefer this — the folder can hold the skill's other assets) and dir/SKILL.md (the directory is one skill). The result is sorted by skill name, so a chart built from it is stable regardless of the order the filesystem happened to return.

skillsFromDir is Node-only — it reads the filesystem — but importing agentfootprint/context in a browser bundle stays safe: node:fs is imported lazily inside the call, never at module load.

Why it refuses a URL

A skill body is instructions to a model. Where it came from is therefore a security property, not a convenience: content fetched at run time from somewhere else is content someone else can change after you reviewed it. So the loader accepts a local directory and nothing else — a URL is refused by name rather than fetched, because "these files are mine" is a claim you can only make about a path on your own disk at build time.

skillsFromDir: 'https://example.com/skills' is a https URL, not a local
directory. Skill bodies are instructions to a model, so this loader only reads
files you own at build time — fetch remote content yourself, review it, and pass
defineSkill(...) the result.

Each file is read once, at load. A body edited afterwards does not reach a run already in flight.

Every refusal names what to go fix

A loader that says "malformed frontmatter" over a directory of forty files has told you nothing. So:

ProblemThe message names
Missing / unclosed --- block, missing name or description, empty body, an unusable namethe file
Two files claiming the same skill nameboth files
A URL, a UNC path, a missing directory, a file passed where a directory was expectedthe argument

A directory with no SKILL.md at all is an error too, not an empty array — pointing at the wrong folder should not look like "you have no skills yet".

SkillsFromDirOptions carries viaToolName and surfaceMode, applied uniformly to every skill the directory yields; both mean exactly what they mean on defineSkill.

When to use Skills vs Steering vs Instruction

You wantUse
Always-on persona / tone / formatdefineSteering
Conditional rule (predicate-based)defineInstruction({ activeWhen })
LLM-activated playbook + toolsdefineSkill (this guide)
Cross-run statedefineMemory

Anti-patterns

  • Don't put always-relevant content in a skill. If it's relevant 100% of the time, it belongs in the system prompt or as Steering. Skills are for sometimes-relevant.
  • Don't define dozens of tiny skills. The LLM picks by description; too many descriptions to scan = analysis paralysis. 3–10 focused skills is the sweet spot.
  • Don't put sensitive credentials in a skill body. Skill bodies are LLM-readable plaintext; treat them as you would the system prompt.

Next steps

  • Skills, explained — the conceptual essay (why this design, cross-provider correctness, three-stage anatomy)
  • Tools guide — the underlying tool primitive Skills compose over

On this page

What a Skill isDefine and attach a skillWhy progressive disclosure mattersWhen a skill activatesInside a skill graph, the pick also MOVES the graphParallel tool batches route on EVERY call, in call order (9.16.0)Skills the graph doesn't route are open (8.4.0)Declaring the graph — the object form is the canonical spellingChecking the graph before you run it — graph.checkup()Start rules as data — match beside whenExamples on a start rule — the phrasings it claimsThe context every predicate is judged onTwo start laws, and why the check-up will not pick oneScoping tools on the flat arm — scopeToolsBody-contract checks that wait for the agent — DeferredBodyContractWhere the graph stands, and why it resets — CursorMoveWhen a hint cannot fireAdvanced: the fluent builderPer-provider surface selection (surfaceMode)Per-mode runtime dispatch'tool-only' requires that read_skill is what activates the skill (8.5.0)Steps as data (steps, 9.18.0)The steps surface, namedArtifact vocabularies (produces / consumes, 9.25.0)What gets checked, and what it cannot seePer-skill brains — model switching (9.19.0)The brains surface, namedLong-context refresh (refreshPolicy)SkillRegistry — centralized governanceRegistry-level defaults — new SkillRegistry({ surfaceMode, providerHint }) (v2.5)registry.toTools() — explicit composition (v2.5)Per-skill tool gating — autoActivateSkills authored as files — skillsFromDirWhy it refuses a URLEvery refusal names what to go fixWhen to use Skills vs Steering vs InstructionAnti-patternsNext steps