Pay for the index, not the book: extending an agent with markdown on disk
Smith exposes skill names up front, loads bodies on demand, lets workspaces shadow bundled procedures, and graduates corroborated memory into the same mechanism.
On this page
Efferent’s Smith coder recently learned how to author static gate rules. The implementation did not add TypeScript-compiler lore to the system prompt. It added one markdown file:
packages/smith/skills/gate-rule-authoring.md
The profile agent sees one line saying what that skill is for. Only when it decides to write a custom rule does it load the worked examples, compiler-API patterns, failure contract, and message-writing guidance. A specialized capability entered the product without making every unrelated coding turn carry it.
That is the whole skills design: pay for a small index every turn; pay for a body only in the turns that use it. The implementation is intentionally not a plugin system. Skills are instructions, never executable code — markdown on disk, ordinary file ownership, no registry or network.
The on-disk contract
A workspace skill lives at .efferent/skills/<name>.md:
---
name: database-migrations
description: Create and verify this repository's forward-only SQL migrations.
---
# Database migrations
1. Add the next numbered migration under …
2. Never edit a migration already released …
3. Verify with `bun run migrate:test` …
Only description is required; the filename stem becomes the name when frontmatter omits it. Invalid names, missing descriptions, unreadable files, and malformed frontmatter are skipped. Optional context must not be able to brick a coding run.
The parser is deliberately small and forgiving rather than pretending markdown frontmatter is a typed configuration language. The strict boundary is what Smith puts into the model: a bounded name, a clipped description, and later a clipped body.
Tier one: the index
At session setup, Smith reads metadata from two places:
- the workspace’s
.efferent/skills/; - the skills shipped with Smith itself.
Workspace names win. That precedence is the customization mechanism: if Efferent ships a generic gate-rule-authoring procedure and a repository needs a narrower house version, dropping a file with the same name replaces the bundled one without forking code.
export const discoverSkills = (cwd: string) =>
Effect.gen(function* () {
const workspace = yield* discoverDir(join(cwd, ".efferent/skills"))
const bundled = yield* discoverDir(BUNDLED_SKILLS_DIR)
const names = new Set(workspace.map((skill) => skill.name))
return [
...workspace,
...bundled.filter((skill) => !names.has(skill.name)),
].slice(0, 20)
})
The system prompt gets only the resulting names and descriptions:
export const renderSkillsBlock = (skills: ReadonlyArray<SkillMeta>): string =>
skills.length === 0
? ""
: [
"## Skills available (call load_skill BEFORE doing work one covers)",
...skills.map((skill) => `- ${skill.name}: ${skill.description}`),
].join("\n")
Twenty skills cost twenty short lines, not twenty manuals. The hard cap is part of the product behavior: a directory accidentally filled with generated markdown cannot consume the entire prompt.
Tier two: one body, through one tool
load_skill({ name }) reads the workspace copy first, then the bundled copy, and returns at most 12,000 characters. The result enters the transcript like any other tool output, at the moment of maximum relevance.
An unknown name returns a useful failure rather than ENOENT:
if (Option.isNone(body)) {
const available = yield* discoverSkills(cwd)
return yield* Effect.fail({
error: "UnknownSkill",
message:
available.length === 0
? `no skill named "${params.name}" — this workspace defines no skills`
: `no skill named "${params.name}" — available: ${available
.map((skill) => skill.name)
.join(", ")}`,
})
}
The available-name roster matters because models import tool and skill vocabulary from other harnesses. Fresh local evidence beats that prior. The failure stays inside the turn, so the model can correct the name immediately.
Tier three: references stay ordinary
A skill can point to source files, templates, or longer reference documents. Smith does not recursively inject them. The model uses its ordinary read tools if the loaded procedure says they are relevant.
That third tier prevents a subtle failure of “lazy” systems: loading a 1,000-token skill that automatically expands a 20,000-token references directory is eager loading with an extra step. The skill body is an index too. Each deeper read has to be earned by the current task.
This also keeps authority legible. The skill tells the coder how the repository works; the referenced file remains the source of truth for what it currently says. Copying a config or template into the skill would create a prose replica that drifts silently.
A skill can be learned, but not from one anecdote
The current Smith line also writes learned-<topic>.md skills from its workspace memory ledger. That sounds like self-modification until you look at the threshold.
A finished forge run produces candidate memories: a local convention, a build quirk, a dependency fact, a gotcha. Later independent runs can corroborate, update, or invalidate them. Only records that cross the corroboration bar are projected into learned skill files. The generated file even says where it came from:
<!-- AUTO-DISTILLED from .efferent/memory/ledger.jsonl —
rewritten every run. Curate the ledger, not this file. -->
The projection is disposable; the append-only ledger is the evidence. This is the same separation the rest of Efferent uses: observations accumulate in one durable form, while the prompt-facing view can be rebuilt. A single weird session does not become permanent doctrine merely because a model summarized it confidently.
Bundled know-how without built-in policy
gate-rule-authoring shows why the workspace/bundled split is useful. Efferent can ship mechanism knowledge — the plain IdiomRule shape, AST traversal patterns, the fail-closed contract — while the profile decides which actual rules this repository arms. The skill teaches how to write a rule; it does not smuggle a style opinion into every project.
The latest profile flow relies on exactly that distinction. The profile agent explores the repository, proposes packs and custom gates, dry-runs their blast radius, and loads the authoring skill only if the repository needs a rule the shipped packs do not contain. Capability is bundled. Policy remains project-owned.
What markdown cannot guarantee
Skills have no compiler. Rename a command and the old invocation can survive in prose until a real run fails. A 12,000-character cap can cut the section that mattered. The model can see a matching description and still neglect to load it. Shadowing can replace a good bundled procedure with a stale local one.
Those are real costs, but they are visible ones: files can be reviewed, generated skills name their source ledger, workspace overrides are ordinary diffs, and the index bounds the permanent tax. The alternative — one giant system prompt containing every procedure — also drifts, while charging every call for the privilege.
The useful mental model is not “install a skill.” It is a three-level context tree: metadata now, instructions when selected, references when needed. Markdown is merely the storage format. Progressive disclosure is the feature.