Skills
A skill is a named playbook an agent loads on demand. It is a directory
with a SKILL.md file: YAML frontmatter (name + description) followed by a
Markdown body. The agent always sees the name and description of each skill it
is allowed; it pulls the full body into context only when a turn actually calls
for it. That is progressive loading — an agent can “know” twenty skills exist
while paying for the text of only the ones it opens.
See examples/skills/ for a runnable one.
Skills vs. Knowledge. They share a format — YAML frontmatter over a
Markdown body, progressive loading — which makes them easy to confuse. A
skill is curated: a human writes it, and it is canonical until a human
changes it. A Knowledge concept is learned: the engine
extracts it from finished conversations, it is provenance: observed, and it
sits below skills in the context priority ladder — earned trust, not
authored trust.
Format
---
name: refunds # must equal the directory name
description: When and how to process a refund # the Level-1 trigger text
triggers: [refund, money back] # optional: deterministic activation (below)
companions: [refund-policy] # optional: skills this one cannot work without
---
<the full playbook body — loaded only on demand>
Frontmatter is parsed tolerantly. A skill’s canonical name is its directory name,
which the name: field must match.
Progressive loading: two levels
- Level 1 — metadata only. A context provider injects an
<available_skills>list into the system prompt — the name, the one-line description and thetriggers:of every allowed skill — telling the model to load a skill before acting on it. Cheap, and always present for allowed skills. This is the routing table, and it is generated: it cannot disagree with the allowlist, so do not hand-write one in a prompt file (see Drift guards). - Level 2 — the full body. A built-in
load_skilltool returns the skill body on demand. It enforces the agent’s skill allowlist and is wired automatically whenever the agent has any allowed skills — you do not add it totools_allow. - Deterministic activation —
triggers:. When the user message contains one of the skill’striggers, the body is injected for that turn — no model decision, noload_skillcall. Only matched skills, only that turn. Use it for skills that MUST fire on known phrases; model loading stays as the fallback for everything else. Matching is on whole words, case-insensitive and accent-folded:presentefires on um presente and presénte, never inside apresente.
The Level-1 list is budgeted like any other context fragment (see Context); the Level-2 body only costs tokens on the turns that open it.
Only trigger a skill that can finish the turn alone
A triggers: match is not a hint — the body lands in the prompt with the
authority of an instruction. So put triggers only on a skill that is
self-sufficient for the turn it fires on.
The failure mode is counter-intuitive: injecting a skill that is only part of
the answer is worse than injecting nothing. Give the model a reference table
whose procedure lives in a companion skill, and it now holds a plausible
half-recipe — so it never calls load_skill for the other half, and improvises
the missing part. A precise trigger on the wrong kind of skill still breaks the
turn.
Reference tables, vocabularies and lookup maps are the skills to leave on level 1. Whole procedures (“run this journey”, “recover from this error”) are the ones worth triggering.
Always-on skills: skills_eager
A skill that every turn needs — output format, the marker vocabulary, how to recover from a failed tool — should not depend on the model choosing to load it. Name it on the agent and its body is in the prompt on every turn:
Insika.agent("consultant") do
skills_eager "recommendation-formatting", "tool-error-recovery"
# skills_eager # or: every allowed skill (a corpus that fits the budget)
# skills_eager false # or: none — the default
end
An eager skill also leaves level 1: it is absent from <available_skills> and
load_skill refuses to serve it. There is no level 2 left to fetch, and a catalog
pointing at a body already in the prompt only invites a call that pays for a
duplicate.
Why the agent decides, and not the skill
Eagerness used to be an eager: true key in the SKILL.md frontmatter. That put
the decision on the wrong object: skills are shared. escalation-to-human,
recommendation-formatting and tool-error-recovery each sit in several agents’
allowlists, and one flag on the skill forced one decision onto every agent holding
it — with no way to be always-on for the agent that needs it and discretionary for
the one that does not.
skills_eager is a per-agent list, so the same shared skill can be both. The
frontmatter key is ignored — insika doctor flags any skill still carrying it,
and names the agent setting that replaced it.
A name that is not in the agent’s skills allowlist is a no-op (eagerness is
intersected with what the agent is allowed to see); doctor flags that too.
Keep the discretionary skills on the load path
Making everything eager is a trap, and the reason is not the tokens: it costs you
the signal. When every body is present on every turn, “which skills were active”
is always “all of them”, and you can no longer tell which one the model reached for.
The load_skill call is the only record of that choice — it is a persisted tool
message, so it shows up in the transcript on its own.
So the split is: eager for what the turn always needs, load_skill for what the
turn might need. The second group is where you want the model’s choice on the
record, because that is the group where a wrong choice is worth seeing.
The token trade is real but smaller than it looks: eager bodies sit at a fixed position ahead of the history, so they belong to the cacheable prefix, and they are still evictable under budget pressure, unlike the pinned identity. Conditional injection is what breaks that prefix, on exactly the turns it fires.
Seeing which skills were active, and why
The load path is legible for free: load_skill is a tool, so the call is a
persisted message and shows up in the transcript on its own. The deterministic paths
are not a call, so the engine reports them itself — with a reason per skill:
| reason | what it means |
|---|---|
eager |
the agent’s skills_eager names it, so every turn gets it |
trigger:<phrase> |
this message matched that triggers: entry — the phrase as authored, so you can find the line to edit |
pack |
a plugin’s own context provider supplied the body |
Where it shows up, per turn, in the Studio session screen:
- an activation card in the transcript thread, placed at the top of its turn and in the same visual language as a tool result, so a context-injected skill and a model-loaded one read the same way;
- the Context card, next to that category’s token count, for the after-the-fact audit;
- the
skill_activatedevent (skills: [{name, reason}],source: "context"), with full task/session correlation.
All three are computed from what actually reached the prompt after the budget
cut: a body the budget evicted is reported as an eviction, never as an activation.
A turn that mixes both paths is labelled mixed, and each line keeps its own reason.
Where skills live: the store, over a disk seed
- Skills live as rows in SQLite — one row per skill, holding the entire
SKILL.md, versioned (recent revisions are retained). - On-disk
SKILL.mdfiles in configured roots are loaded as a seed, then the store is overlaid on top — the store wins. A reload swaps the index atomically, so edits take effect without a restart.
⚠️ Committing a
.mdfile to the repo does not make a skill show up on a running deployment. The on-disk file is only a seed for a fresh box; a live box serves the store, and a deploy does not rewrite the database. Editing is a runtime operation (Studio / API / DSL), not a commit. See Context.
Pairs that must not break: companions:
Injecting part of an answer is worse than injecting nothing. Give the model a line
map whose query-construction rules live in another skill and it holds a plausible
half-recipe — so it never calls load_skill for the other half, and improvises the
missing part. Measured on a real pack: the map arrived by trigger, the rules did not,
and the searches came out malformed. Twice.
Declare the dependency and it travels with whatever brought it — a trigger match, the
agent’s eager set, or a load_skill call (which returns both bodies in the one call):
companions: [query-construction]
Two deliberate limits:
- One level, no transitive walk. A cycle would be a hang and a chain a budget blowout, and “cannot work without” is a direct relationship.
- Never widens an allowlist. A companion the agent is not allowed to load is
simply absent;
insika doctorflags the declaration instead.
Specializing a shared skill for one agent
Skills are shared on purpose: escalation-to-human belongs in several agents’
allowlists. But sometimes one agent needs a different version of the same skill —
its own return policy, its own store name — and forking it under a second name
throws the sharing away and leaves two things to keep in step.
So the store has a second scope, and resolution is a precedence chain with one more dimension:
for agent A, skill <name>: (A, <name>) in the agent scope,
then <name> in the shared scope
Three cases fall out of that one rule:
| case | what exists in the store |
|---|---|
| shared | only the shared record — every agent gets the same body |
| override | both — the agent’s wins, for that agent only |
| agent-private | only the agent record — invisible elsewhere, and the name may collide freely |
The name never changes. An override keeps saying name: escalation-to-human
inside, because it is that skill, specialized; the allowlist, the <available_skills>
list, load_skill and the activation card all keep showing the bare name. What
decides which record you get is its position in the store, never the frontmatter —
otherwise an override would clobber the shared skill for everybody.
Write one with agent:, and remove it the same way (which un-specializes, leaving
the shared skill in place):
dispatch(:write_skill, { name: "escalation-to-human", agent: "store-demo", content: md })
dispatch(:delete_skill, { name: "escalation-to-human", agent: "store-demo" })
In the Studio: Skills → specialize for this agent, which seeds the override from the shared body.
Making a new skill “show up”
For an agent to actually use a skill, both conditions must hold:
- The skill exists as a row in the store (a
SKILL.mdwritten into it). - The skill is in that agent’s
skillsallowlist (nil= all,[]= none,[names]= those — see Agents).
Miss either and the skill is invisible: not in the store → nothing to load; not
in the allowlist → the model never sees it in <available_skills>.
Two ways to satisfy both:
- Via a definition/pack import. The import writes each skill directory into
that agent’s scope, without overwriting another agent’s or a shared skill, and
sets the agent’s
skillsallowlist authoritatively from the skills present. Dropping a skill removes it from the allowlist, not storage. Keep the definition complete. Packs previously imported into shared scope must be reimported from their original sources; the overwritten shared body cannot recover each agent’s version. - Directly (Studio / API / DSL). Write the skill (upserts the row and reloads
the catalog atomically — live immediately), then attach it to the agent(s) by
adding its name to the
skillsallowlist.
Verify it showed up
- In the Studio agent’s Skills section, the skill is listed and allowed.
- In a turn, the skill appears in the
<available_skills>list and the model canload_skillit (the body loads on demand). - If the model never mentions it → check the allowlist (condition 2). If
load_skillerrors → the store row is missing or misnamed (condition 1); thename:frontmatter must equal the directory name.
Drift guards
A skill catalog drifts against the prose that routes to it, and every way it happened
on the pilot was silent — found by reading a customer conversation days later. So the
routing table is generated (above), and insika doctor reports the residue the
generator cannot remove. Every check takes mechanical inputs only — names, allowlists,
agent identities — because one false positive is enough for an operator to stop
reading the doctor:
| finding | what it means |
|---|---|
| a prompt file names a skill outside that agent’s allowlist | leftover hand-written routing: the model is told to use something it cannot load |
| a shared skill’s body names one of its own holders | specialized text in shared clothing — the other holders are served that store’s policy as their own. Specialize it instead |
| a body references another catalog skill without declaring it a companion | the pair can still arrive apart |
| a declared companion is outside an agent’s allowlist | the pair cannot travel for that agent, and the engine will not widen the allowlist |
a skill still declares eager: in its frontmatter |
the key is ignored; the decision moved to the agent |
| an agent marks a skill eager that it does not allow | the name is a no-op |
See also
- Context — how the skills list is budgeted into a turn.
- Agents — the skills allowlist.
- Tools —
load_skilland deferred-tool progressive disclosure. - Plugins — shipping skills inside a plugin, and the two extension tiers.
- Knowledge — the learned counterpart: engine-extracted concepts, same format, earned trust.
examples/skills/— progressive loading, runnable.