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

  1. Create .petite/skills/<name>/SKILL.md files: YAML frontmatter with name and description, then a body below a closing ---.
  2. 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.

References