Skills

13. Model-invoked skills

View this stage's code on GitHub →

Every invocation so far needed a human to type /name. This stage lets the model decide: a Skill tool, advertised like any other, that the model calls with a name it matched against the descriptions already sitting in the system prompt. The one thing this depends on is description quality — "Database utilities" gives the model nothing to match a request against, while "use this skill when the user asks about database migration status" does. Two frontmatter flags control who's allowed to trigger a skill this way: disable-model-invocation: true (user only, e.g. for anything with side effects you don't want the model deciding to run) and user-invocable: false (model only).

Your task

  1. Add a Skill tool (name, optional args) that resolves a skill by name and returns its body as the tool result — same resolution as slash commands, triggered by a tool call instead.
  2. Tell the model in the system prompt to call it when a skill's description matches the request. Descriptions must say when to use a skill, not just what it does, or the model has nothing to match against.

The solution

Try the task above first. When you want to compare, this is exactly what changed since stage 12 (just diff 12 13 shows the same).

Show the solutionHide the solution +176 −67 lines
.petite/skills/beaver/SKILL.md+8 −0
⋯ ---name: beaverdescription: Use this skill when the user asks for the database migration status.--- Respond with exactly one word: dam Do not add any other text, punctuation, or formatting.
main.py+18 −6
⋯ MAX_TURNS = 20  def main():    parser = argparse.ArgumentParser(description="petite-harness: a tiny AI coding assistant")    parser.add_argument("-p", "--prompt", required=True, help="the task to ask the model")    parser = argparse.ArgumentParser(description="petite: a tiny AI coding assistant")    parser.add_argument(        "-p", "--prompt", required=True, help="the task to ask the model"    )    args = parser.parse_args()     if not API_KEY:⋯ def main():     skill_bodies = resolve_slash_command(args.prompt)    if skill_bodies is not None:        print(f"[agent] resolved {len(skill_bodies)} skill invocation(s)", file=sys.stderr)        print(            f"[agent] resolved {len(skill_bodies)} skill invocation(s)", file=sys.stderr        )        messages = [{"role": "user", "content": body} for body in skill_bodies]    else:        messages = [{"role": "user", "content": args.prompt}]⋯ def main():        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)        print(            f"[agent] turn {turn}: calling model with {len(messages)} message(s)",            file=sys.stderr,        )         response = client.chat.completions.create(            model=MODEL,⋯ def main():            print(message.content)            return         print(f"[agent] turn {turn}: {len(tool_calls)} tool call(s) requested", file=sys.stderr)        print(            f"[agent] turn {turn}: {len(tool_calls)} tool call(s) requested",            file=sys.stderr,        )         for call in tool_calls:            arguments = json.loads(call.function.arguments)            print(f"[agent] executing {call.function.name}({arguments})", file=sys.stderr)            print(                f"[agent] executing {call.function.name}({arguments})", file=sys.stderr            )             result = execute_tool(call.function.name, arguments) 
skills.py+107 −42
⋯ """Stage 12: level 3 — bundled scripts A skill folder can hold more than SKILL.md: scripts/, references/,assets/ — level 3 of progressive disclosure. These never enter contexton their own; the body has to point at them, and only then does themodel load/run them (here, scripts/, via the Bash tool). A body says "Run `scripts/sha256.sh`" using a path relative to its OWNskill folder — but the agent runs from the project root. Only tellingthe model which folder that is isn't enough: models often run therelative path as-is and get "file not found". So before the bodyreaches the model, its bundled paths (scripts/, references/, assets/)are rewritten to project-root paths, e.g. "scripts/sha256.sh" becomes".petite/skills/badger/scripts/sha256.sh".Stage 13: model-invoked skills (the Skill tool) Every invocation so far needed the user to type "/name". Now the modelcan invoke a skill itself: it sees the name + description of every skillin the system prompt (level 1, unchanged), and when one matches theuser's request, it calls a "Skill" tool with that name — exactly like itcalls Read or Bash — and we hand back that skill's body as the toolresult, with the same folder-header + argument substitution as before. This only works if descriptions say WHEN to use a skill, not just whatit does — "Database utilities" gives the model nothing to match arequest against; "Use this skill when the user asks about databasemigration status" does. Invocation control (Claude Code extension, not core to the open spec):two optional frontmatter flags decide who's allowed to trigger a skill:   disable-model-invocation: true   only "/name" can trigger it  user-invocable: false            only the Skill tool can trigger it Useful for a skill with side effects you don't want the model decidingto run on its own (disable-model-invocation), or background knowledgethat isn't a meaningful "/command" for a user to type (user-invocable).""" import osimport re import yamlfrom pydantic import BaseModel, Field, field_validatorfrom pydantic import BaseModel, ConfigDict, Field, field_validator SKILLS_DIR = ".petite/skills" ⋯ SKILLS_DIR = ".petite/skills"class SkillMeta(BaseModel):    """Validates a SKILL.md's frontmatter against the Agent Skills spec."""     model_config = ConfigDict(populate_by_name=True)     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)    disable_model_invocation: bool = Field(        default=False, alias="disable-model-invocation"    )    user_invocable: bool = Field(default=True, alias="user-invocable")     @field_validator("name")    @classmethod⋯ def _split_frontmatter(text):    return {}, text  def discover_skills(skills_dir=SKILLS_DIR):    """Level 1: scan skills_dir, return validated SkillMeta for each one.def _load_skill_meta(entry, skills_dir, warn=True):    """Read and validate one skill folder's frontmatter.     Folders that fail validation (bad frontmatter, name/folder mismatch)    are skipped with a warning on stderr rather than crashing the agent.    Returns a SkillMeta, or None if the folder/SKILL.md is missing or the    frontmatter fails validation (optionally warning on stderr).    """    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    skill_md_path = os.path.join(skills_dir, entry, "SKILL.md")    if not os.path.isfile(skill_md_path):        return None         with open(skill_md_path) as f:            frontmatter, _body = _split_frontmatter(f.read())    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    try:        meta = SkillMeta(**frontmatter)    except Exception as e:        if warn:            print(                f"[skills] skipping {entry!r}: invalid frontmatter ({e})",                file=sys.stderr,            )        return None         if meta.name != entry:    if meta.name != entry:        if warn:            print(                f"[skills] skipping {entry!r}: name {meta.name!r} must match folder name",                file=sys.stderr,            )            continue        return None     return meta  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.    """    skills = []     if not os.path.isdir(skills_dir):        return skills         skills.append(meta)    for entry in sorted(os.listdir(skills_dir)):        meta = _load_skill_meta(entry, skills_dir)        if meta is not None:            skills.append(meta)     return skills ⋯ def build_skills_system_prompt(skills):    for skill in skills:        lines.append(f"- {skill.name}: {skill.description}")     lines += [        "",        "If a skill matches the user's request, call the Skill tool with its name",        "and follow the instructions it returns.",    ]     return "\n".join(lines)  ⋯ def resolve_slash_command(prompt, skills_dir=SKILLS_DIR):    all and return a list of substituted bodies, one per skill — in the    order they appeared, each with the same trailing argument text.     Expansion stops at the first token that isn't a real skill name; that    token and everything after it becomes the shared $ARGUMENTS text.    Expansion stops at the first token that isn't a real, user-invocable    skill name; that token and everything after it becomes the shared    $ARGUMENTS text.     Returns None if the prompt doesn't start with a recognized skill    invocation at all, so the caller falls back to the raw prompt.⋯ def resolve_slash_command(prompt, skills_dir=SKILLS_DIR):    i = 0    while i < len(tokens) and tokens[i].startswith("/"):        candidate = tokens[i][1:]        if load_skill_body(candidate, skills_dir) is None:        meta = _load_skill_meta(candidate, skills_dir, warn=False)        if meta is None or not meta.user_invocable:            break        expanded_names.append(candidate)        i += 1⋯ def resolve_slash_command(prompt, skills_dir=SKILLS_DIR):    args = tokens[i:]     return [        _with_folder_header(name, substitute_arguments(load_skill_body(name, skills_dir), args), skills_dir)        _with_folder_header(            name,            substitute_arguments(load_skill_body(name, skills_dir), args),            skills_dir,        )        for name in expanded_names    ] ⋯ def _with_folder_header(name, body, skills_dir=SKILLS_DIR):    return f"Skill: {name} (located at {folder})\n\n{body}"  def resolve_skill_invocation(name, args_text="", skills_dir=SKILLS_DIR):    """Resolve a skill by name with a raw argument string (as the Skill    tool receives it), substitute placeholders, and add the folder header.     Returns None if the skill doesn't exist. Raises ValueError if the    skill exists but has disable-model-invocation: true, so the Skill    tool can turn that into a clear message instead of leaking the body.    """    meta = _load_skill_meta(name, skills_dir, warn=False)    if meta is None:        return None     if meta.disable_model_invocation:        raise ValueError(            f"skill {name!r} can only be invoked directly by the user (/{name}), not by the model"        )     body = load_skill_body(name, skills_dir)    args = args_text.split() if args_text else []    return _with_folder_header(name, substitute_arguments(body, args), skills_dir)  def substitute_arguments(body, args):    """Replace $ARGUMENTS, $ARGUMENTS[n] and $n placeholders in a skill    body with the given positional arguments.
tools.py+43 −19
⋯ """Stage 7: Tool classes with Pydantic validationStage 13: the Skill tool (model-invoked skills) Two problems with the previous stage's TOOLS list: 1. The JSON Schema for each tool's parameters was hand-written and could   drift from what execute() actually accepts.2. execute_tool did `fn(**arguments)` on whatever JSON the model sent —   a missing or mistyped argument surfaced as a raw Python TypeError deep   inside the tool, not a clear validation error. A Pydantic model per tool fixes both: the schema comes from`model_json_schema()` (one source of truth), and arguments are validatedand coerced through that model *before* execute() ever runs.Every tool so far was self-contained. This one depends on skills.py: itlooks up a skill the model names, substitutes its arguments, and returnsthe body as the tool result — the same mechanism slash commands use,just triggered by a tool call instead of a "/name" prefix.""" import subprocess from pydantic import BaseModel, Field, ValidationError from skills import resolve_skill_invocation  class ReadArgs(BaseModel):    file_path: str = Field(description="The path to the file to read")⋯ class BashArgs(BaseModel):    command: str = Field(description="The command to execute")  class SkillArgs(BaseModel):    name: str = Field(description="The name of the skill to use")    args: str = Field(default="", description="Optional arguments for the skill")  class Tool:    name: str    description: str⋯ class ReadTool(Tool):    args_model = ReadArgs     def execute(self, file_path):        with open(file_path) as f:            return f.read()        try:            with open(file_path) as f:                return f.read()        except Exception as e:            return f"Error reading {file_path}: {e}"  class WriteTool(Tool):    name = "Write"    description = "Write content to a file, creating it if needed or overwriting it if it exists"    description = (        "Write content to a file, creating it if needed or overwriting it if it exists"    )    args_model = WriteArgs     def execute(self, file_path, content):        with open(file_path, "w") as f:            f.write(content)        return f"Wrote to {file_path}"        try:            with open(file_path, "w") as f:                f.write(content)            return f"Wrote to {file_path}"        except Exception as e:            return f"Error writing {file_path}: {e}"  class BashTool(Tool):⋯ class BashTool(Tool):        return output  ALL_TOOLS = [ReadTool(), WriteTool(), BashTool()]class SkillTool(Tool):    name = "Skill"    description = "Load a skill's instructions into the conversation"    args_model = SkillArgs     def execute(self, name, args=""):        try:            result = resolve_skill_invocation(name, args)        except ValueError as e:            return str(e)        if result is None:            return f"Unknown skill: {name}"        return result  ALL_TOOLS = [ReadTool(), WriteTool(), BashTool(), SkillTool()] TOOLS = [tool.spec() for tool in ALL_TOOLS] ⋯ def execute_tool(name, arguments):        validated = tool.args_model(**arguments)    except ValidationError as e:        return f"Invalid arguments for {name}: {e}"     return tool.execute(**validated.model_dump())

Try it

Run this stage's own code:

just stage 13
cd .stages/13
# bundled skill "beaver": "Use this skill when the user asks for the database migration status." (answers: dam)
just run "What is the database migration status?"

Expect: dam — the model called the Skill tool on its own; you never typed its name

.stages/13 is a git worktree: your checkout stays on main, so just keeps working there. Compare with the previous stage with just diff 12 13, 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

← 12. Bundled scripts (level 3) 14. Subagents →