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:
- Write a new Python Tool class and register it
- Bake the workflow into the system prompt
- 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
$ARGUMENTSFormat rules:
- The file opens with a YAML frontmatter block (delimited by
---), which must include anameand adescriptionfield. nameonly allows lowercase letters, digits, and hyphens (regex:^[a-z0-9]+(?:-[a-z0-9]+)*$).- Everything after the closing
---is the Skill body (the instruction text). $ARGUMENTSis a special placeholder — when the model invokes this Skill, theargsparameter 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 NoneIf 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 scanscan() 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, bodyThe 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 formatWhere 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 formatWhen 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 rebuildTwo-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-isUltimately, 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 instructionsDesign 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
| File | Purpose |
|---|---|
extensions/skills/loader.py | SkillLoader: scanning, caching, refreshing, parsing frontmatter |
extensions/skills/prompt.py | format_skills_for_prompt(): formats the SkillMeta list into prompt text |
tools/builtin/skill.py | SkillTool: handles model Skill invocations, reads files, substitutes $ARGUMENTS |
runtime/prompt_builder.py:254 | {{available_skills}} slot substitution logic |
runtime/factory.py:27-33 | Creates 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.