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
defineSkilldoes — the body lands only when the LLM asks for it, and withautoActivatethe 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 addautoActivate: '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:
- 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.
- Context discipline — the LLM sees ONLY the playbook for what it's currently doing. No cross-domain confusion.
- Capability scoping — with
autoActivate: 'currentSkill',process_refundis 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 withgatedTools.
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:
- Looks up
billingin the agent's registered skills - Formats the body as a tool result the LLM sees on the next iteration
- Starts offering
billing.toolsin the tool list for the rest of the turn — but only ifbillingsetautoActivate: 'currentSkill'. Without it those tools were already in the registry before the LLM asked, and this step changes nothing - Emits
agentfootprint.skill.activatedfor 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.
compareMatchersdeliberately 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:
| code | severity | what it proves |
|---|---|---|
example-misses-own-rule | error / warning | The 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-earlier | error | An 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-default | warning | The 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-unclaimed | warning | No 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:
| where | role | when it is read |
|---|---|---|
match: { intent, examples } (tier 2) | scoring material — the classifier judges new messages against them | every turn, at run time |
examples: [...] beside match/when (tier 1) | test material — the check-up runs the matcher over them | build 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 form | fluent 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 / scopeTools | the 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'):
surfaceMode | System slot (next iteration) | read_skill tool result |
|---|---|---|
'system-prompt' | body lands here | confirmation only |
'tool-only' | body SUPPRESSED | body delivered verbatim |
'both' | body lands here | body delivered verbatim |
'auto' (default) | body lands here | confirmation only |
Two consequences worth knowing:
'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.'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'), callresolveSurfaceMode(provider, model)yourself and pass the concrete mode, or set a registry-level default vianew 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 it —
skip_step(reason)puts the decline on the record (agentfootprint.skill.step_skipped) and the declaredonSkipdecides:'advance'(default) moves on,'hold'keeps the step current; - use an escape hatch —
read_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: thetoolit runs (one of the skill's own tools) and thenotethe model reads.OnSkipPolicy— the'advance' | 'hold'union behindonSkip: what the framework does when the model skips a step.StepPointer— where a procedure stands:skillId(the tenant), a 1-basedstep,total, and theskippedindexes the pointer moved past;step === total + 1means 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 aStepPointer.StepPlan— the frozen build-time fold of one stepped skill: its steps, the hold-out candidatetoolNames, and itsonSkippolicy.buildSkipStepTool— builds theskip_stepintegrity tool for custom wiring;SKIP_STEP_TOOL_NAMEis its reserved name ('skip_step').defineStepsHint— the advisory injection auto-registered for stepped agents, telling the model a declared procedure is in progress;StepsHintOptionsoverrides itsidorbody, andSTEPS_HINT_METADATA_KEYis 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:
| # | satisfier | applies to |
|---|---|---|
| 1 | a tool in scope declares wants for K — the redemption path the framework honors at dispatch | skill (any of its tools) · step (the tool that step runs) |
| 2 | anything on the agent declares produces: K — any skill, or any step of any skill | skill · step |
| 3 | an 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: anLLMProviderport plus an optionalmodel. The value type ofSkillGraphOptions.providersand thedeciderfield's shape.EscalationPolicy— aProviderChoiceplusafterRefusals, 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 declarecarriesForcedToolChoice, a strict single-line parse + one re-ask + the caller's fallback everywhere else.ConstrainedEnumPickRequest— its request bag (provider, model, catalog prompt, messages, theallowedenum,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:
- Per-skill explicit
surfaceModewins.defineSkill({ surfaceMode: 'both' })is honored regardless of registry default. - Registry's
surfaceModector opt (if set + not'auto'). - 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:
- Token-efficient discovery — the auto-attached
read_skillembeds the catalog in itsdescription(every iteration's tool list pays the cost).list_skillslets the LLM browse on demand;read_skill's description can stay terse. For ~20+ skill registries, this matters. - Permission gating — pass
read_skillthroughgatedToolslike any other tool, so areadonlyrole can seelist_skillsbut notread_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.activeSkillId | Visible 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:
| Problem | The message names |
|---|---|
Missing / unclosed --- block, missing name or description, empty body, an unusable name | the file |
| Two files claiming the same skill name | both files |
| A URL, a UNC path, a missing directory, a file passed where a directory was expected | the 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 want | Use |
|---|---|
| Always-on persona / tone / format | defineSteering |
| Conditional rule (predicate-based) | defineInstruction({ activeWhen }) |
| LLM-activated playbook + tools | defineSkill (this guide) |
| Cross-run state | defineMemory |
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
Window strategies
Three ways to keep the live context window inside its budget — summarize, slide, or drop on a token budget — sharing one refusal engine and one ledger, so every strategy records what it removed, by id.
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.
