Skills
8. Skills (level 1)
View this stage's code on GitHub →A skill is a folder of instructions the model can load into context on demand instead of always — pasting every skill's full instructions into every prompt would blow the context window past a handful of skills. The fix is progressive disclosure: level 1 only ever loads a skill's name and description into the system prompt, nothing else, no matter how large the skill's actual instructions are. .petite/skills/ deliberately isn't .claude/skills/ — the SKILL.md format follows the open Agent Skills standard, but the folder convention here is this project's own.
Your task
- Create
.petite/skills/<name>/SKILL.mdfiles: YAML frontmatter withnameanddescription, then a body below a closing---. - Scan that directory, parse just the frontmatter of every skill found, and add a system prompt listing each as
- name: description. Never read the body at this stage.
The solution
Try the task above first. When you want to compare, this is exactly what changed since stage 7 (just diff 7 8 shows the same).
Show the solutionHide the solution +119 −3 lines
main.py+10 −3
⋯ """Stage 6: extract a tools moduleStage 8: skills, level 1 (advertise) main.py is now just the agent loop — tool specs and implementations livein tools.py (see that file for why this split happened).main.py now prepends a system message listing available skills (name +description only) before the loop starts — see skills.py for the why.""" import argparse⋯ import sys from openai import OpenAI from skills import build_skills_system_prompt, discover_skillsfrom tools import TOOLS, execute_tool API_KEY = os.getenv("OPENROUTER_API_KEY")⋯ def main(): messages = [{"role": "user", "content": args.prompt}] skills = discover_skills() system_prompt = build_skills_system_prompt(skills) if system_prompt: print(f"[agent] advertising {len(skills)} skill(s)", file=sys.stderr) messages.insert(0, {"role": "system", "content": system_prompt}) for turn in range(1, MAX_TURNS + 1): print(f"[agent] turn {turn}: calling model with {len(messages)} message(s)", file=sys.stderr)
skills.py+109 −0
⋯ """Stage 8: skills, level 1 (advertise) A "skill" is a folder of instructions an agent loads on demand instead ofalways keeping in context — the Agent Skills open standard:https://agentskills.io/specification Skills live under .petite/skills/<name>/SKILL.md (petite-harness's ownfolder, not Claude Code's .claude/skills/ — the SKILL.md *format* is thegeneric open standard, the directory convention is implementation-specificand this project uses its own). Progressive disclosure has three levels; this stage implements level 1only: scan every skill folder, parse just the YAML frontmatter (name +description), and put that list in a system prompt so the model knowswhat exists — without ever loading a SKILL.md body into context.""" import os import yamlfrom pydantic import BaseModel, Field, field_validator SKILLS_DIR = ".petite/skills" class SkillMeta(BaseModel): """Validates a SKILL.md's frontmatter against the Agent Skills spec.""" name: str = Field(max_length=64) description: str = Field(min_length=1, max_length=1024) license: str | None = None compatibility: str | None = Field(default=None, max_length=500) @field_validator("name") @classmethod def validate_name(cls, value): import re if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", value): raise ValueError( f"name {value!r} must be lowercase alphanumeric with single hyphens, " "no leading/trailing/consecutive hyphens" ) return value def _split_frontmatter(text): lines = text.splitlines() if not lines or lines[0].strip() != "---": return {}, text for i in range(1, len(lines)): if lines[i].strip() == "---": frontmatter_text = "\n".join(lines[1:i]) body = "\n".join(lines[i + 1 :]).lstrip("\n") return yaml.safe_load(frontmatter_text) or {}, body return {}, text def discover_skills(skills_dir=SKILLS_DIR): """Level 1: scan skills_dir, return validated SkillMeta for each one. Folders that fail validation (bad frontmatter, name/folder mismatch) are skipped with a warning on stderr rather than crashing the agent. """ import sys skills = [] if not os.path.isdir(skills_dir): return skills for entry in sorted(os.listdir(skills_dir)): skill_md_path = os.path.join(skills_dir, entry, "SKILL.md") if not os.path.isfile(skill_md_path): continue with open(skill_md_path) as f: frontmatter, _body = _split_frontmatter(f.read()) try: meta = SkillMeta(**frontmatter) except Exception as e: print(f"[skills] skipping {entry!r}: invalid frontmatter ({e})", file=sys.stderr) continue if meta.name != entry: print( f"[skills] skipping {entry!r}: name {meta.name!r} must match folder name", file=sys.stderr, ) continue skills.append(meta) return skills def build_skills_system_prompt(skills): if not skills: return None lines = ["You have access to the following skills:", ""] for skill in skills: lines.append(f"- {skill.name}: {skill.description}") return "\n".join(lines)
Try it
Run this stage's own code:
just stage 8
cd .stages/08
mkdir -p .petite/skills/otter && cat > .petite/skills/otter/SKILL.md <
Expect: "otter" appears in the answer, with no mention of its body content
.stages/08 is a git worktree: your checkout stays on main, so
just keeps working there. Compare with the previous stage with just diff 7 8,
or browse it on GitHub;
just clean-stages removes the worktrees.
Where this fits
The whole agent; this stage builds the highlighted part. Click any part to jump to its stage.