Skip to content

Part III · Build It / Ch. 10

Part III Chapter 10: Writing Experience into an Instruction Manual — Skill ​

Xia A selects and reads one guide from a row of skill booklets while Lao Z looks on and the others remain closed.

Chapter guide: Part III · Build It expands on Chapter 2. Part I · Concepts described a Skill as the discoverable instruction layer for experience. This chapter works out how to add a minimal SkillCatalog to mini-agent. Nothing goes into src; what gets cleared up is how discovery, loading, conflicts and failure semantics should be designed.

Xiao A noticed that every time his Agent handled a "release task," he had to spell out the same procedure again: run the tests first, then create the tag, then push. He had stuffed it into the system prompt a few times; the context kept getting longer, and the procedure was often squeezed out by a new task. He asked Lao Z: "Can I write the procedure up as one instruction, so the Agent reads it only when it needs to?"

"You can. What you want is a Skill instruction bundle — text, not code," Lao Z said. "But 'only when it needs to' is the key part. Cramming dozens of Skills into the context at once makes your Agent answer questions while hauling the whole manual behind it. So think through first how discovery and loading stay separate."

Xiao A pressed: "Then what exactly is the difference between this and writing the procedure straight into the system prompt?"

"The difference is when it enters the context," Lao Z said. "A system prompt rides along on every request, so your release procedure keeps taking tokens even when the user asks 'what's the weather today'. A Skill loads on demand: the model first sees a lightweight index, decides whether this task needs it, and only then pulls the body in. mini-agent's runAgent currently takes one system string parameter (see options in agent.ts) and has no dynamic injection channel; for a Skill to get in, the loop needs one more action: 'read the instructions'. This chapter doesn't go into src — it works out what the interface of that action looks like."

Xiao A: "Then is there any difference between writing the procedure into system now and writing it into options.system in agent.ts?"

"None — they both end up in the same parameter of client.stream," Lao Z said. "runAgent passes options.system straight through to client.stream({ system, ... }), and nothing else in the loop touches it. So the question isn't 'where do I write it', it's 'when does it enter'. Write it into system and it occupies characters on every turn, on every user message: the contextMaxChars budget counts it, and the model reads it every turn. A Skill wants exactly the opposite — it shows up only when the task is relevant, and gives up its place when the task ends."

Lao Z laid out a comparison table:

Dimensionthe procedure in the system prompta Skill loaded on demand
when it enters the contexta fixed payload every turnloaded only when the model judges it relevant
the cost it occupiesresident, always billedon demand, trimmable once used
how you update itchange code and redeployedit a Markdown file
several coexistingcrammed into one place, diluting each otherindependent, fetched by name
mini-agent todayimplemented (options.system)not implemented (sketched in this chapter)

"The last column is the current state: one implemented, one pure sketch. Don't read it as 'time to switch to Skills' — you don't yet have a single Skill that has been loaded formally. The point of the sketch is to get the shape of the interface right first, not to make you write it in this chapter."

A Skill is a soft constraint, not code ​

"Set the boundary first," Lao Z said. "A Skill is a Markdown instruction with frontmatter: name, description, then the steps in the body. It influences the model's decisions through text; the model may not comply. Real hard constraints come from tools and approval. So a Skill's interface is responsible only for 'find the instruction and return it', not for 'making the model obey'."

md
---
name: release-process
description: fixed procedure for release tasks: test first, then tag, then push
---

1. Run npm test; stop if it fails
2. Once it passes, create a git tag
3. Push to the remote

Xiao A looked at the Markdown: "Wait — isn't this just a prompt fragment shown to the model? What's the difference between it and a tool's description field?"

"The difference is who triggers it and when it takes effect," Lao Z said. "A tool's description is resident — the model sees it every turn, because it might be called at any time. A Skill's body enters the context temporarily; when the task is over it should give up its place. More important: a tool description says 'how this action is used', while a Skill says 'what order this class of task should follow'. One speaks of actions, the other of procedure. In mini-agent, createWorkspaceTools writes one description sentence each for read / write / bash (see tools.ts) — those are action notes. Your release procedure's 'test first, then tag' is cross-step orchestration that no single tool can state clearly. That layer is exactly what a Skill adds."

"Then since it's a soft constraint, what if the model doesn't comply?" Xiao A asked.

"Two fallbacks," Lao Z said, counting on his fingers. "First: the model reads the instruction, skips 'test first' and calls bash straight away to create the tag — then denyBashByDefault (see approval.ts) stops bash before that happens. The hard constraint is the backstop. Second: write 'did the tests actually run' into the Skill's body, so the model sees the evidence in the tool results — for instance, require the npm test output to be pasted back into the context. The Skill tells; tools and approval backstop. mini-agent has only the latter layer today; the former is empty."

Xiao A was still uneasy: "Does that soft-constraint nature discount the value of a Skill? I write the instruction at length and the model still ignores it."

"No, because what you want isn't 'obedience', it's 'higher probability'," Lao Z said. "When the model reads a structured procedure relevant to the task, the odds it follows are far higher than when it has to dig the same paragraph out of a 2,000-character system prompt. A soft constraint can't reach 100% execution, but it can turn 'often forgotten' into 'rarely forgotten'. For actions that genuinely cannot be wrong, tools and approval make a hard gate — denyBashByDefault intercepting bash is deterministic; you don't bet on the model's behavior."

Constraint layerExampleWhen it failsmini-agent today
Skill bodyrun the tests before releasingthe model ignores it, the step is skippednot implemented (sketch)
tool schemabash.command must be a stringthe model passes a bad argument, the tool returns isErrorimplemented
approval policybash denied by defaultthe policy itself errors, the loop throwsimplemented (denyBashByDefault)

"Of these three layers, only the Skill layer persuades with text; the two below it enforce with code. Which layer you treat as the defense line depends on whether the step is 'better followed' or 'must be followed'."

Designing a minimal SkillCatalog ​

Lao Z sketched an interface in mini-agent's style:

ts
// Design sketch: SkillCatalog (not in src; confirm the requirement first)
export interface Skill { name: string; description: string; content: string; }

export interface SkillCatalog {
  /** Scan the directory; return every Skill's name and description (the body is not loaded) */
  discover(): Promise<SkillSummary[]>;
  /** Load the full body by name */
  load(name: string): Promise<Skill>;
}

"Separating discovery from loading is the core of Skill design," Lao Z said. "discover reads only the frontmatter — the name and the description; load is the one that reads the body. The Agent can then judge from an index of dozens of Skills and load the body of exactly the one it needs, so the context doesn't swell in proportion to the number of Skills. That is the three-step pattern Part I · Concepts calls discovery / trigger / progressive loading."

Xiao A stared at the interface for a while: "Is this discover a bit like the part where createWorkspaceTools collects the tools?"

"Yes, that's the closest thing to it," Lao Z said. "Before the loop starts, runAgent uses options.tools.map((tool) => tool.spec) to gather every tool's name and description and puts them into the tools parameter of client.stream (see agent.ts). What the model sees is an index of tools, not the execution logic of each one. discover does the same job: hand the model a catalog of which Skills are available, and hold the bodies back. A tool spec is a resident index; a Skill summary is an on-demand index — the first is present every turn, the second refreshes once when the task arrives."

"Then why not just have one getAll() and read everything at once?" Xiao A asked.

"Because with everything read at once, the 'lightweight index' stops existing," Lao Z said. "getAll() loads the bodies too; the more Skills there are, the heavier every request gets. You are still stuffing the context, just through a different door. The basis for the split is that the reading costs differ: frontmatter is one short read, a body may run to dozens of lines. Let the model decide from a cheap index, and spend the expensive body only on the one that is actually used."

"So how does the body actually get into the context?" Xiao A went on.

"Two candidates, each with a price," Lao Z said, gesturing. "One: insert it as a role: 'user' message. But in mini-agent the Message type (see types.ts) makes user messages visible to the model, so the Skill body becomes part of the conversation history — it is still there next turn and keeps taking tokens. Two: make it a 'read a Skill' tool that the model calls itself. Then the body arrives as a tool result, and trimContext (see context.ts) can drop it together with its assistant message when the context goes over budget. The second fits mini-agent's temperament better: let the model fetch it with a tool rather than have the host push it in. But that also means you add a read_skill tool, and we're back to the old rule: the need first, the tool second."

How the body enters the contextLifetimeCan trim remove itCost
host pushes a role: user messagebecomes conversation history, residentremoved only when the whole context is over budgetstill costs tokens next turn
model calls the read_skill toolexists as a tool resultdropped as a group with its assistant messagerequires a new tool

Failure semantics: the diagnostic must let the model fix it ​

Another question came to Xiao A: "What if the Skill's frontmatter is written wrong? What happens when loading fails?"

"A Skill load failure is different from a tool failure: it happens before the model decides, so the model never sees the raw exception," Lao Z said. "Which means the failure has to become a description the model can read, not a stack thrown at the loop."

ts
// Design sketch: the shape of a load-failure diagnostic
export type LoadResult =
  | { ok: true; skill: Skill }
  | { ok: false; reason: "not_found" | "frontmatter_invalid" | "missing_description"; detail: string };

"Look at missing_description," Lao Z said, pointing at it. "A Skill without a description should be marked invalid at the discover stage. Otherwise the model never sees it in the index, which is the same as that Skill not existing."

Xiao A tried to see the bright side: "Why not have the whole discover throw and stop the moment a frontmatter is wrong? Isn't that cleaner?"

"Clean, but it trades the large for the small," Lao Z shook his head. "Picture thirty Skills, one of them missing description in its frontmatter. discover throws and the other twenty-nine become unusable. It's the same reasoning as mini-agent's loadSession: it (see session.ts) throws invalid session JSON at line N on one bad line, because a session is a single chain — one broken line scrambles everything after it. A Skill directory is a set of sibling files; one broken file should not drag the others down. So discover should behave like pi-mono's loadSkills: collect parse failures into diagnostics and return them. Skipping the bad one still raises an alarm, and the host decides what to do."

"Then what should the detail field say?" Xiao A pressed.

"Enough that the model can fix it from the text," Lao Z said. "Just saying frontmatter_invalid is useless — the model doesn't know which line is wrong. detail has to carry the file name, the field name and the expected type, something like release-process.md: field 'description' missing, expected a one-line summary. mini-agent's tool failures do the same: when the read tool gets illegal input it returns 'invalid read input: path must be a string' (see tools.ts) — it doesn't throw 'bad input'."

"Then what do the discover stage and the load stage each validate? Why can't they be merged into one?" Xiao A continued.

"Because 'does the model know it exists' and 'can the model use it' are two independent things," Lao Z said:

StageWhat it validatesWhen validation fails
discoverfrontmatter parses, name / description present, name uniquethat Skill does not enter the index; the others are unaffected
loadbody complete, steps readable, content non-emptythat Skill reports a load failure; it stays in the index

"Merge them into one stage and you can no longer tell 'the model never saw this Skill' from 'the model saw it but couldn't open it'. The first is an index problem, the second a content problem, and the responses are completely different. Split into two stages, and 'invisible' and 'unopenable' become two diagnosable states."

"Then should a load failure give the model a chance to retry?"

"It can, but the retry has to be bounded," Lao Z said. "The model reads the detail, edits the file itself, and calls load once more — that is reasonable self-repair. But an unlimited retry spins on a broken Skill and burns the budget. The retry limit should be backstopped by the host, the way maxTurns backstops the tool loop: runAgent doesn't keep spinning forever just because a tool keeps failing. Whether that backstop belongs in the loop or in the Skill tool's own implementation is something the sketch has to settle too."

Xiao A wanted to chain the whole path: "Can you draw the whole chain? I want to confirm what exactly the model sees, and at which step."

Lao Z drew a sequence diagram:

runAgent loop (schematic)                 SkillCatalog         Skill file
   │                                      │                    │
   │─── discover() ──────────────────────►│──read frontmatter─►│
   │◄── [ {name, description}, ... ] ─────│◄──index, no body───│
   │   model judges: use a Skill?         │                    │
   │(on a hit)                            │                    │
   │─── read_skill("release-process") ───►│                    │
   │   tool runs → load(name) ───────────►│──read full body───►│
   │◄── Skill{name, description, content}─│◄──body─────────────│
   │   body enters context as tool result │                    │
   │   model executes the steps           │                    │

"Watch the time gap between the two arrows: discover happens when the loop starts, before the model's first decision; load waits until the model has decided 'I want to use this', with the model's own judgment in between. A system prompt needs no judgment; a Skill needs judgment every time," Lao Z said, pointing at the read_skill line in the diagram.

Conflicts and versions: don't build it until the need arrives ​

Xiao A thought of trouble that had not happened yet: "What if two Skills have the same name? Do we need version numbers?"

"Design it when the conflict actually happens," Lao Z said. "What would trigger it today? You have exactly one release-process. When you have two Skills with the same name and genuinely need to tell them apart before the model decides, the requirement will tell you whether SkillCatalog should return { name, version } or { namespace, name }. Designing version conflicts while you have one Skill is borrowing demand into an interface — the debt Part III Chapter 6 talked about."

Xiao A wasn't satisfied: "But if I leave the version field out now and two Skills collide in name later, won't load(name) return the wrong one?"

"It will. But have you costed that?" Lao Z said. "Adding a version field means: discover's return type goes from SkillSummary[] to a structure carrying version, and every caller has to change; load's signature goes from (name) to (name, version?); and you have to decide what a missing version means — newest or oldest. The whole set of that, for a conflict that does not exist today. mini-agent's BeforeToolCall didn't design 'merging multiple policies' either; it was just (call) => {approved, reason} (see types.ts), because you needed one policy. When two policies genuinely have to stack, come back and change the type — that beats carrying a version field now. Interface debt is easier to repay late than early, because late you know exactly how much you owe."

"What about namespaces? release/git and release/npm, say?" Xiao A pressed again.

"Same logic — that's path design, not version design," Lao Z said. "A namespace solves 'same name, different domain', and it appears only when you have the requirement for a cross-domain Skill library. Today you don't even have one domain. Rather than guess what a namespace looks like, make name uniqueness a constraint enforced when a Skill is written: two files in the Skill directory may not repeat the same frontmatter name, and discover reports a diagnostic when it detects a duplicate. That check is worth doing now, and it's cheap."

Xiao A: "And if two elements in the SkillSummary[] that discover returns share a name, which one should load(name) return?"

"That is exactly the problem, and it stays unsolved because you shouldn't let it happen," Lao Z said. "Report duplicate skill name: release-process in a.md and b.md at the discover stage, and load never meets a same-name ambiguity. Blocking a conflict at the earliest gate where it can be detected is far cleaner than agonizing over 'which one wins' at load time."

Conflict mechanismWhat it resolvesWhen it is neededCost today
name uniqueness checksame-name ambiguitynowtiny: one check during discover
version numbersame name, different versionswhen concurrent maintenance appearstype, signature and default policy all change
namespacesame name, different domainswhen a cross-domain Skill library appearspath convention and query semantics to design

The comparison with pi-mono ​

"pi-mono's loadSkills searches for SKILL.md recursively, respects .gitignore, and collects parse failures into diagnostics instead of throwing and stopping," Lao Z said. "Your minimal SkillCatalog is a separate sketch. Compare how each design exposes instructions to the model and reports loading failures; do not assume they read files in the same stages."

Xiao A's mind started wandering: "Should I just copy pi-mono's loadSkills straight into mini-agent?"

"Don't," Lao Z stopped him. "pi-mono's loadSkills — the .gitignore parsing, the recursive scan, the diagnostics aggregation — is the product of a heavy scenario. What it faces are real repositories that may hold a hundred Skills scattered across a monorepo. mini-agent has never loaded a single Skill formally. Copy it and you get a pile of code nobody calls, and then you invent test scenarios to make it 'work' — that is the 'borrowing demand into an interface' Part III Chapter 8 covered. First walk the minimal chain with your one release-process: discovery → trigger → loading → body into context. When the second and third Skill appear, look again at which complexity was genuinely pulled up by a requirement."

"Then which parts of pi-mono should I copy?" Xiao A asked.

"Copy two principles, not the implementation," Lao Z said. "The first is separating file loading from prompt exposure. pi-mono's loader reads and parses the full SKILL.md. The Skill object retains metadata and file paths, not the body. formatSkillsForPrompt normally renders only name, description, and location, with instructions to read the file when needed. The explicit /skill:name path also lets the host read the body, so not every path depends on a model tool call. The second is diagnosable failure: parse failures become diagnostics instead of a thrown error that stops everything. You already have both. The rest — respecting .gitignore, recursive scanning, aggregation across directories — is prepared for 'a hundred Skills scattered through a monorepo'. You have one directory and one file; you can't use it."

The complexity in pi-mono's loadSkillsDoes mini-agent need it now?
recursive scan for SKILL.mdno, one directory is enough
respecting .gitignoreno, there is no repository scenario
diagnostics aggregation instead of throwingyes (a core principle of this chapter)
separating discovery from loadingyes (a core principle of this chapter)

"Note one detail that runs the other way: pi-mono's discovery is tolerant of a single broken Skill (it collects diagnostics), while mini-agent's loadSession has zero tolerance for a bad line (it throws). Both are right, because the data structures differ."

Summary ​

A Skill loader lets the Agent read procedural experience in when it needs it, instead of stuffing that experience into every request. The core is separating discovery from loading: discover reads only the frontmatter and hands the model a lightweight index; load is the one that reads the body. The index is cheap, the body expensive; take them on demand and the context will not swell in proportion to the number of Skills. This resembles something mini-agent already has: every turn, runAgent collects tools.map((tool) => tool.spec) into a tool index and sends it to the model. A tool's spec is a resident index, present every turn; a Skill's summary is an on-demand index, refreshed once when the task arrives.

A Skill is a soft constraint: the interface is responsible for finding the instruction, not for making the model obey it; hard constraints still come from tools and approval as the backstop. What it optimizes is not 100% execution but turning "often forgotten" into "rarely forgotten"; actions that genuinely cannot be wrong need a deterministic gate like denyBashByDefault. The body has two ways into the context: a user message pushed by the host stays resident, while a read_skill tool call by the model brings it in as a tool result that trimContext can drop as a group. The second fits mini-agent's temperament better, but it means a new tool — back around to "the need first, the tool second."

Failures must become diagnostics the model can read, at a granularity down to the file name and the field name, so the model can fix the file itself. discover and load each check their own thing: the first decides "does the model know it exists" (frontmatter, name uniqueness), the second decides "can the model use it" (body integrity). A broken Skill should not drag the whole set down — a directory is a set of sibling files, not a single chain like a session — so parse failures are collected into diagnostics and the host decides whether to skip or to alarm. Conflict handling runs the opposite way: do the uniqueness check now; version numbers and namespaces wait until the requirement actually appears, and then the interface grows the matching shape.

One more time, at the end: mini-agent does not implement Skills, and this chapter is a sketch. SkillCatalog, LoadResult and the sequence diagram are design sketches, not code in src; runAgent currently has exactly one injection channel, the system string. If your Agent is going to take on Skills, the conclusion of the sketch is this: cut the interface shape along "discovery / trigger / progressive loading", design the failure semantics along "diagnosable, locatable, non-contagious", and choose the body channel along "the model fetches it itself". On the day you actually write it, let the requirement tell you what is still missing.

Stage Acceptance ​

Do not modify src. Write the sketch notes in examples/mini-agent:

  1. Write a SKILL.md of no more than 10 lines for one of your repeated tasks (with name and description).
  2. Draw the call sequence of discover and load: at which step the model sees the index, at which step it gets the body. Mark which path the body takes once it is in the context — which route through Message (a user message or a tool result) — and say why you chose that path.
  3. Construct a Skill whose frontmatter is missing description, and write down at which stage and in what diagnostic shape it should be rejected. The diagnostic must contain the file name and the missing field name; assert that the detail string the model reads lets it locate the specific file.