Code Agent Anatomy (06): How Do Skills Dynamically Extend an Agent's Capabilities?

A deep dive into the Skills dynamic extension mechanism in MyCodeAgent: how a single Markdown file gives an agent new capabilities without writing any Python code, covering the full lifecycle from definition, scanning, and prompt injection to invocation and execution.

·12 min read·AI Engineering

This is the sixth article in the MyCodeAgent source code reading series. In the previous installment, we walked through the complete tool system flow: registration → schema generation → orchestration → execution → result protocol. This article focuses on the Skills dynamic extension mechanism: a system that lets an agent acquire new capabilities by simply dropping a Markdown file — no Python code required.


Starting with a Question

Built-in tools (Bash, Read, Edit, etc.) are registered when the agent starts, so their capabilities are fixed. But what if you want the agent to execute a specific workflow — say, "do a code review following our team's conventions" or "generate a commit message in a specific format"? You have a few options:

  1. Write a new Python Tool class and register it
  2. Bake the workflow into the system prompt
  3. Create a SKILL.md file

The third approach is the Skills mechanism: describe a set of instructions in a Markdown file, and the agent loads it on demand — no restart needed. This article walks through the full lifecycle, from creating a Skill file all the way through definition → scanning → injection → invocation → execution.


Step 1: Defining a Skill (the SKILL.md format)

Skills live under the skills/ folder in the project root. Each subdirectory holds one SKILL.md file, formatted like this:

---
name: code-review
description: Perform a code review on a specified file following team conventions
---
 
Please review the following code, paying attention to:
1. Logical correctness
2. Edge case handling
3. Naming conventions
 
$ARGUMENTS

Format rules:

  • The file opens with a YAML frontmatter block (delimited by ---), which must include a name and a description field.
  • name only allows lowercase letters, digits, and hyphens (regex: ^[a-z0-9]+(?:-[a-z0-9]+)*$).
  • Everything after the closing --- is the Skill body (the instruction text).
  • $ARGUMENTS is a special placeholder — when the model invokes this Skill, the args parameter it passes will be substituted here.

The file itself is the complete Skill definition. No Python code needed.


Step 2: Startup Scanning — SkillLoader Builds the Cache

At startup, factory.py assembles all runtime components. The Skills-related logic looks like this:

# runtime/factory.py:27-33
host._skills_prompt = ""
if host.enable_skills and _project_has_skill_files(host.project_root):
    from extensions.skills import SkillLoader
 
    host._skill_loader = SkillLoader(host.project_root)
else:
    host._skill_loader = None
host._refresh_skills_prompt()

_project_has_skill_files() performs a quick check — does the skills/ directory exist and does it contain any SKILL.md files:

# runtime/factory.py:103-105
def _project_has_skill_files(project_root: str) -> bool:
    skills_dir = Path(project_root) / "skills"
    return skills_dir.is_dir() and next(skills_dir.rglob("SKILL.md"), None) is not None

If the check passes, SkillLoader is created. It maintains two key pieces of state:

# extensions/skills/loader.py:29-31
self._skills: Dict[str, SkillMeta] = {}        # Cache of parsed skills (name → SkillMeta)
self._last_scan_mtime: float = 0.0              # Max mtime across all files at last scan
self._last_scan_count: int = 0                  # Number of files at last scan

scan() logic:

# extensions/skills/loader.py:33-61
def scan(self) -> List[SkillMeta]:
    files = self._iter_skill_files()   # rglob("SKILL.md") walks all files
    skills: Dict[str, SkillMeta] = {}
    max_mtime = 0.0
 
    for path in files:
        stat = path.stat()
        max_mtime = max(max_mtime, stat.st_mtime)   # Track the latest mtime
        parsed = self._parse_skill_file(path)
        if parsed:
            skills[parsed.name] = parsed
 
    self._skills = skills
    self._last_scan_mtime = max_mtime
    self._last_scan_count = len(files)
    return self.list_skills(refresh=False)

Each SKILL.md is parsed into a SkillMeta object:

# extensions/skills/loader.py:14-20 (SkillMeta dataclass)
@dataclass
class SkillMeta:
    name: str         # name from frontmatter
    description: str  # description from frontmatter (shown in the Skill tool prompt)
    path: str         # absolute path to SKILL.md
    base_dir: str     # directory relative to project_root (used as execution context)
    mtime: float      # file mtime (used for cache invalidation)

Incremental Refresh: refresh_if_stale()

Skills support hot reload — you can add or modify a SKILL.md without restarting the agent, and the change will be picked up automatically. Cache staleness is determined by comparing file state:

# extensions/skills/loader.py:63-71
def refresh_if_stale(self) -> List[SkillMeta]:
    if not self._skills:
        return self.scan()
 
    current_max_mtime, current_count = self._get_skills_state()
    # Re-scan if any file's mtime changed, or if the file count changed (add/delete)
    if current_max_mtime != self._last_scan_mtime or current_count != self._last_scan_count:
        return self.scan()
    return self.list_skills(refresh=False)

Design trade-off: This caching strategy is deliberately lightweight, checking only the maximum mtime and file count rather than diffing each file individually. The upside is minimal overhead. The downside is that in rare edge cases (two files modified simultaneously where one ends up with the same mtime as before) a refresh might be missed — but this is entirely acceptable for the Skills use case.


Step 3: Parsing Frontmatter — _parse_frontmatter()

SkillLoader doesn't depend on PyYAML; it uses a simple hand-rolled parser:

# extensions/skills/loader.py:142-168
def _parse_frontmatter(content: str) -> Optional[Tuple[Dict[str, str], str]]:
    lines = content.splitlines()
    if not lines or lines[0].strip() != "---":
        return None
 
    end_idx = None
    for i in range(1, len(lines)):
        if lines[i].strip() == "---":
            end_idx = i
            break
 
    if end_idx is None:
        return None
 
    frontmatter_lines = lines[1:end_idx]
    body = "\n".join(lines[end_idx + 1:])   # Everything after --- is the Skill body
 
    frontmatter: Dict[str, str] = {}
    for line in frontmatter_lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        if ":" not in stripped:
            return None
        key, value = stripped.split(":", 1)
        frontmatter[key.strip()] = value.strip().strip("\"'")
 
    return frontmatter, body

The logic is straightforward: find the first ----delimited block, parse each line as key: value, and return the remainder as the body. It doesn't support YAML nesting — only flat key-value pairs — which is perfectly sufficient for the Skills use case.


Step 4: Generating the Skill List Text for Injection into the System Prompt

Back in factory.py, _refresh_skills_prompt() is called immediately after SkillLoader is created:

# runtime/host.py:191-202
def _refresh_skills_prompt(self) -> None:
    if not self.enable_skills or self._skill_loader is None:
        self._skills_prompt = ""
        return
    refresh = self.config.skills_refresh_on_call
    if refresh:
        self._skill_loader.refresh_if_stale()
    elif not self._skills_prompt:
        self._skill_loader.scan()                   # Force a scan on cold start
    budget = int(os.getenv("SKILLS_PROMPT_CHAR_BUDGET", "12000"))
    from extensions.skills.prompt import format_skills_for_prompt
    self._skills_prompt = format_skills_for_prompt(
        self._skill_loader.list_skills(refresh=False), budget
    )

format_skills_for_prompt() converts a list of SkillMeta objects into a text block, constrained by char_budget:

# extensions/skills/prompt.py:10-27
def format_skills_for_prompt(skills: Iterable[SkillMeta], char_budget: int) -> str:
    items = sorted(list(skills), key=lambda skill: skill.name)
    if not items:
        return "(none)"
 
    lines: list[str] = []
    used = 0
    for skill in items:
        line = f"- {skill.name}: {skill.description}"
        line_len = len(line) + 1
        if used + line_len > char_budget and lines:
            break   # Budget exceeded, truncate
        lines.append(line)
        used += line_len
 
    return "\n".join(lines) if lines else "(none)"

The generated text looks like this:

- code-review: Perform a code review on a specified file following team conventions
- gen-commit-msg: Generate a commit message in Conventional Commits format

Where does this text end up? The Skill tool's prompt contains a {{available_skills}} slot that receives it.


Step 5: The {{available_skills}} Slot Injection

The _load_tool_prompts() method in prompt_builder.py is responsible for stitching together all tool prompts into the Tool Contracts layer of the system prompt. It contains this key piece of logic:

# runtime/prompt_builder.py:254-256
if self._skills_prompt and "{{available_skills}}" in prompt_value:
    prompt_value = prompt_value.replace("{{available_skills}}", self._skills_prompt)
prompts.append(prompt_value)

This means the prompt text in prompts/tools_prompts/skill_prompt.py contains {{available_skills}}, which gets replaced with the actual Skill list text when the system prompt is assembled.

The definition in skill_prompt.py is:

Available Skills
{{available_skills}}

After substitution, what the model sees in its system prompt is:

Available Skills
- code-review: Perform a code review on a specified file following team conventions
- gen-commit-msg: Generate a commit message in Conventional Commits format

When the Skills list changes (e.g., a new SKILL.md is added), set_skills_prompt() clears the system prompt cache, forcing a rebuild on the next turn:

# runtime/prompt_builder.py:196-202
def set_skills_prompt(self, prompt: str) -> None:
    normalized = prompt or ""
    if normalized == self._skills_prompt:
        return
    self._skills_prompt = normalized
    self._cached_assembly = None   # Clear cache; next get_prompt_assembly() call will rebuild

Two-layer caching: The system prompt has a fingerprint cache (the stable layer is reused when unchanged). When _skills_prompt changes, it triggers a fingerprint change that invalidates the cache. This guarantees get_prompt_assembly() always returns the latest Skills list while avoiding a filesystem scan on every conversation turn.


Step 6: The Model Invokes the Skill Tool — SkillTool Execution

When the model sees the list of available Skills in its system prompt and decides to invoke one, it emits a function call:

{
  "name": "Skill",
  "arguments": {
    "name": "code-review",
    "args": "src/main.py"
  }
}

This call travels through the ToolOrchestrator → ToolExecutor pipeline and ultimately reaches SkillTool.run().

SkillTool is conditionally registered in _register_builtin_tools() — only when _skill_loader is not None:

# runtime/host.py:180-188
if self._skill_loader is not None:
    from tools.builtin.skill import SkillTool
    self.tool_registry.register_tool(
        SkillTool(
            project_root=self.project_root,
            skill_loader=self._skill_loader,
            refresh_on_call=self.config.skills_refresh_on_call,  # Whether to refresh cache on every call
        )
    )

The execution steps of SkillTool.run():

# tools/builtin/skill.py:56-129 (condensed)
def run(self, parameters: Dict[str, Any]) -> ToolResult:
    name = parameters.get("name")
    args = parameters.get("args") or ""
 
    # Step 1: Check cache; if not found, force a refresh and try again
    skill_meta = self._skill_loader.get_skill(name.strip(), refresh=False)
    if not skill_meta:
        skill_meta = self._skill_loader.get_skill(name.strip(), refresh=True)
    if not skill_meta:
        return self.error_result(...)   # Return NOT_FOUND error
 
    # Step 2: Read SKILL.md content from disk (real-time, always gets the latest version)
    raw_content = skill_path.read_text(encoding="utf-8")
 
    # Step 3: Parse frontmatter and extract body
    _frontmatter, body = _parse_frontmatter(raw_content)
 
    # Step 4: Substitute args into the $ARGUMENTS placeholder
    expanded = _apply_arguments(body, args)
 
    # Step 5: Prepend base_dir context and return the expanded Skill content
    content = f"Base directory for this skill: {base_dir}\n\n{expanded}".strip()
    return self.success_result(data={"name", "base_dir", "content": content}, ...)

$ARGUMENTS substitution logic:

# tools/builtin/skill.py:132-138
def _apply_arguments(body: str, args: str) -> str:
    trimmed_args = args.strip()
    if "$ARGUMENTS" in body:
        return body.replace("$ARGUMENTS", trimmed_args)  # Placeholder found → substitute in place
    if trimmed_args:
        return f"{body}\n\nARGUMENTS: {trimmed_args}"    # No placeholder → append to end
    return body                                           # No args → return as-is

Ultimately, SkillTool.run() returns the expanded Skill content as a string — the instruction body with $ARGUMENTS substituted. The model receives this text and proceeds to execute the instructions within it.


Full Lifecycle Recap

skills/code-review/SKILL.md    ← You write your instructions here


[Startup] SkillLoader.scan()        ← Parse frontmatter, build name → SkillMeta cache


[Startup] format_skills_for_prompt()  ← Generate "- code-review: ..." text


[Each turn] ContextBuilder._load_tool_prompts()
        → {{available_skills}} in skill_prompt is replaced with the list
        → Injected into the Tool Contracts layer of the system prompt


[Model decision] LLM sees the available Skills list, emits: Skill(name="code-review", args="src/main.py")


[Execution] SkillTool.run()
        → get_skill() checks cache
        → read_text() reads file from disk
        → _apply_arguments() substitutes $ARGUMENTS
        → Returns the expanded instruction text


[Model receives] Gets Skill content, continues execution per instructions

Design Highlights

1. Zero-code extensibility Adding a new capability requires only a Markdown file — no Python code, no agent restart.

2. On-demand loading The full Skill content (the complete SKILL.md body) is only read from disk when the model actively invokes it. The system prompt only carries a "name + description" summary for each Skill, avoiding the cost of stuffing every Skill's full body into every conversation's context.

3. Hot reload refresh_if_stale() detects file changes via mtime + count comparison, with no restart required. When SKILLS_REFRESH_ON_CALL=true, every Skill tool invocation triggers a staleness check.

4. Character budget SKILLS_PROMPT_CHAR_BUDGET (default 12000) caps the total length of Skill descriptions injected into the system prompt, preventing context window overflow when many Skills are defined.

5. Conditional registration When the skills/ directory doesn't exist, SkillTool is never registered in the tool registry, so it never appears in the model's schema. This eliminates the "tool exists but no Skills are available" scenario that would result in pointless invocations.


Key Source Locations

FilePurpose
extensions/skills/loader.pySkillLoader: scanning, caching, refreshing, parsing frontmatter
extensions/skills/prompt.pyformat_skills_for_prompt(): formats the SkillMeta list into prompt text
tools/builtin/skill.pySkillTool: handles model Skill invocations, reads files, substitutes $ARGUMENTS
runtime/prompt_builder.py:254{{available_skills}} slot substitution logic
runtime/factory.py:27-33Creates SkillLoader at startup and triggers the initial scan
runtime/host.py:173-202_register_builtin_tools() conditionally registers SkillTool; _refresh_skills_prompt() updates the prompt

Next up: context engineering — how the agent manages an ever-growing conversation history, and what happens when the compression threshold is triggered.