A model that can do anything is a model that does nothing well. Wire up a hundred tools and a shelf of how-to documents, dump them all into the prompt on every turn, and three things break at once: you pay for tokens the conversation does not need, the model's attention spreads thin across options it will never use, and the one instruction that mattered drowns in irrelevant noise. This is context pollution, and it worsens as you add capabilities.
A skill is the antidote. It bundles a capability — its instructions, the tools it needs, and a short description of when to reach for it — into one unit that stays off-stage until the conversation calls for it. The trick is progressive disclosure: keep a cheap card for every skill in context, and load a skill's full payload only when it is selected.
flowchart LR C["context window"] --> T1["skill cards<br/>always loaded<br/>cheap"] T1 -.->|"model picks one"| P["skill payload<br/>instructions + tools<br/>loaded on demand"] style T1 fill:#166534,color:#fff style P fill:#7c3aed,color:#fff
What a skill contains
Three parts make up a skill. The metadata is a name plus a one-sentence description of when to use it — the only text that lives in context permanently, so it earns its words. The instructions are a longer prose block, a fragment of system prompt that teaches the model how to carry out the skill once it is chosen. The tools are the concrete actions the skill can perform, referenced by id and resolved to full schemas only on load.
The point is that the second and third parts are large and the first is tiny. A skill that triages bug reports might carry forty lines of instructions and three tool schemas — a thousand tokens — but its card is a single line. Progressive disclosure is the discipline of paying for the card always and the payload only when needed.
const skills = {
triage: {
description: 'Triage incoming bug reports into priority buckets',
instructions: 'Read each report, label it P0 to P3 by severity and scope...',
toolIds: ['search_issues', 'post_comment']
},
summarize: {
description: 'Summarise a long document into bullet points',
instructions: 'Produce at most five bullets, ordered by importance...',
toolIds: ['read_doc']
}
};
function listSkills() {
return Object.keys(skills).map(function (id) {
return { id: id, description: skills[id].description };
});
}
console.log(listSkills().length);
console.log(listSkills()[0].id + ': ' + listSkills()[0].description);
The description does real work
Because the card is the only part of a skill the model ever sees before choosing, the quality of that one line decides whether the skill gets used at all. A vague entry like helps with stuff describes nothing the model can match against a task, so the skill sits unused no matter how good its instructions are. A precise entry — triage incoming bug reports into priority buckets — names the trigger and the output, so the model reaches for it the moment a user mentions bug reports.
Contrast a skill with a plain tool. A tool is one action the model can call; a skill is a bundle of instructions plus the tools that action needs, loaded together. You reach for a skill when a task needs coaching, not just a single function.
The two-step mechanism
Progressive disclosure is a loop with two steps. First, on every turn, the host calls listSkills() and shows the model only the cards — name and description, nothing more. The model reads those cards and decides whether any skill fits the current task. Second, when a skill is chosen, the host calls loadSkill(id), which returns the full payload — the instructions and the resolved tool schemas — and injects just that one into context for the turn.
Nothing about the other skills ever enters the prompt. A catalogue of fifty skills costs fifty lines on every turn; only the one or two that fire cost their full weight, and only for the turns they fire on. That is the whole reason the structure exists.
flowchart TD T["every turn"] --> L["listSkills<br/>returns cards only"] L --> M["model reads cards<br/>picks a skill or none"] M -->|"picks triage"| LOAD["loadSkill<br/>instructions + resolved tools"] LOAD --> CTX["one payload added to context"] style L fill:#1d4ed8,color:#fff style LOAD fill:#7c3aed,color:#fff
const tools = {
search_issues: { name: 'search_issues', inputSchema: { type: 'object' } },
post_comment: { name: 'post_comment', inputSchema: { type: 'object' } },
read_doc: { name: 'read_doc', inputSchema: { type: 'object' } }
};
const skills = {
triage: { description: 'Triage bug reports', instructions: 'Label by severity...', toolIds: ['search_issues', 'post_comment'] }
};
function loadSkill(id) {
const s = skills[id];
return {
instructions: s.instructions,
tools: s.toolIds.map(function (tid) { return tools[tid]; })
};
}
const payload = loadSkill('triage');
console.log(payload.tools.length);
console.log(payload.tools[0].name);
Why a budget caps the payload
Even with on-demand loading, a turn can select several skills at once, and each payload is large. Without a limit the model can cheerfully load six skills, blow past the context window, and push the user's actual question off the page. A token budget fixes this: estimate the cost of each candidate payload, add skills in priority order, and stop before the running total exceeds the budget.
The estimate need not be exact — a rough token count is enough to make the decision, because you are choosing whether to include a skill, not grading it. The discipline is what matters: treat context as a finite resource, measure what you put into it, and prefer the cheapest skill that covers the task.
const skills = [
{ id: 'triage', description: 'Triage bug reports by severity', tokens: 600 },
{ id: 'search', description: 'Search the codebase for symbols', tokens: 400 },
{ id: 'docs', description: 'Search the documentation site', tokens: 500 }
];
function loadWithinBudget(all, keywords, budget) {
const kw = keywords.map(function (k) { return k.toLowerCase(); });
const picked = [];
let used = 0;
for (const s of all) {
const d = s.description.toLowerCase();
if (!kw.some(function (k) { return d.indexOf(k) !== -1; })) continue;
if (used + s.tokens > budget) break;
picked.push(s.id);
used += s.tokens;
}
return picked;
}
console.log(loadWithinBudget(skills, ['search'], 1000).join(','));
console.log(loadWithinBudget(skills, ['bug', 'search'], 900).join(','));
flowchart LR B["token budget"] --> F["fill with matching skills<br/>in order"] F --> S["stop before exceeding"] S --> R["only what fits<br/>enters context"] style B fill:#b45309,color:#fff style R fill:#166534,color:#fff
A skill's description field is the only part that stays in context on every turn. What does that imply about how you should write it?
Because the card is always in context, every word is paid for on every turn. A precise one-line description lets the model pick the skill without paying for the full instructions until they are actually needed.
What does this print? listSkills builds a card per skill; .map(s => s.id) pulls each id and join stitches them into one string.
const skills = {
calc: { description: 'Run a calculator', instructions: 'add numbers', toolIds: ['add'] },
notes: { description: 'Take notes', instructions: 'capture text', toolIds: ['write'] }
};
function listSkills() {
return Object.keys(skills).map(function (id) { return { id: id, description: skills[id].description }; });
}
console.log(listSkills().map(function (s) { return s.id; }).join(','));
Object.keys returns the skill ids in insertion order: calc then notes.
map pulls each card's id, and join(',') turns ['calc','notes'] into one string.
Write listSkills() that returns an array of { id, description } cards — one per skill in the skills object (the key is the id). Do NOT include instructions or toolIds in the cards.
const skills = {
calc: { description: 'Run a calculator', instructions: 'add numbers', toolIds: ['add'] },
draw: { description: 'Draw a shape', instructions: 'pick a shape', toolIds: ['render'] }
};
function listSkills() {
// return [{id, description}, ...] one card per skill
}
Object.keys(skills) gives the ids in insertion order.
Map each id to { id, description }, pulling description from skills[id]. Leave instructions out.
const skills = {
calc: { description: 'Run a calculator', instructions: 'add numbers', toolIds: ['add'] },
draw: { description: 'Draw a shape', instructions: 'pick a shape', toolIds: ['render'] }
};
function listSkills() {
return Object.keys(skills).map(function (id) {
return { id: id, description: skills[id].description };
});
}
This injectSkill adds a skill's payload to the context. It has a bug: it appends the payload on EVERY call, even when that skill is already in context — so a skill used on five turns appears five times. Fix it so a skill already in the context (matched by skillId) is never added a second time.
function injectSkill(context, skillId, loader) {
const payload = loader(skillId);
context.push({ skillId: skillId, payload: payload });
return context;
}
Before pushing, check whether context already has an entry whose skillId matches.
Array.prototype.some returns true if any element passes the test — use it to guard the push.
function injectSkill(context, skillId, loader) {
if (context.some(function (c) { return c.skillId === skillId; })) return context;
const payload = loader(skillId);
context.push({ skillId: skillId, payload: payload });
return context;
}
Write loadWithinBudget(skills, keywords, budget) that picks which skills to load. Include a skill only if its description contains any of the keywords (case-insensitive). Walk the skills array in order, and stop — WITHOUT adding the current skill — as soon as adding it would push the cumulative tokens over budget. Return the array of chosen skill ids. (SKILLS is already defined for you.)
const SKILLS = [
{ id: 'cal', description: 'calendar and events', tokens: 300 },
{ id: 'mail', description: 'email inbox', tokens: 400 },
{ id: 'doc', description: 'document editor', tokens: 500 }
];
function loadWithinBudget(skills, keywords, budget) {
// include a skill only if its description contains a keyword;
// stop (without adding) when the next skill would exceed budget
}
Lowercase both the description and the keywords, then test each keyword with indexOf.
Track a running token total; break (not skip) the moment the next skill would exceed the budget.
const SKILLS = [
{ id: 'cal', description: 'calendar and events', tokens: 300 },
{ id: 'mail', description: 'email inbox', tokens: 400 },
{ id: 'doc', description: 'document editor', tokens: 500 }
];
function loadWithinBudget(skills, keywords, budget) {
const kw = keywords.map(function (k) { return k.toLowerCase(); });
const picked = [];
let used = 0;
for (const s of skills) {
const d = s.description.toLowerCase();
if (!kw.some(function (k) { return d.indexOf(k) !== -1; })) continue;
if (used + s.tokens > budget) break;
picked.push(s.id);
used += s.tokens;
}
return picked;
}
Recap
- A skill bundles three parts: a short
description(always in context), longerinstructions, andtoolIds. It stays off-stage until it is needed. - Progressive disclosure is a two-step loop:
listSkills()shows only cheap cards every turn;loadSkill(id)injects one full payload only when that skill is chosen. - The description does real work: it is the sole text the model sees when deciding, so a vague card produces a skill that is never called.
loadSkillresolves tool ids to full schemas; the catalogue stores ids, not whole tool definitions.- Guard against re-injection: append a payload once, not on every turn, or context bloats the way the design was meant to prevent.
- A token budget caps how many skills a single turn can load.
Next you will make a tool safe to run — least privilege and the allowlist that stops a tool call from becoming an incident.