DEV Community

Cover image for Code Agent Anatomy (22): Extending from Scratch — Writing a Skill in Markdown
WonderLab
WonderLab

Posted on

Code Agent Anatomy (22): Extending from Scratch — Writing a Skill in Markdown

The Difference Between Tools and Skills

The previous article covered how to add new tools (Python classes with concrete logic, returning structured data).

This article covers Skills — they're completely different from tools:

Tools: Execute specific actions (read files, run commands, search code). They are the agent's "hands," doing deterministic things.

Skills: Give the agent a set of expert instructions, changing how it does things. They are the agent's "manual," describing best practices for a class of tasks.

A concrete example:

  • Read tool: read the file src/main.py for me
  • code-review Skill: give the agent a code review methodology — start with architecture, then logic, then check error handling, finally look at naming conventions

A Skill executes no IO — it simply injects a block of text (instructions) into the current conversation, letting the agent know how to proceed.


The Conclusion First

Writing a Skill requires two steps:

Step What to Do
1. Create SKILL.md Write frontmatter + body instructions, place in skills/<name>/SKILL.md
2. Invoke in conversation The agent uses Skill(name="...") to load it, or the user directly says "use xxx skill"

No need to restart the agent, no need to change any code. Drop the file in place, and the agent can use it on the next scan.


I. The Format of SKILL.md

A Skill file has only two parts: frontmatter (metadata) and body (instruction content).

---
name: code-review
description: Perform a code review on the specified file following team standards
---

# Code Review Guide

Perform a code review on $ARGUMENTS, checking each of the following dimensions:

## 1. Architecture and Design
- Is this module's responsibility singular?
- Does it expose unnecessary internal details?

## 2. Error Handling
- Are all exception paths handled?
- Are error messages clear enough?

## 3. Naming and Readability
- Are variable names and function names self-explanatory?
- Are there necessary comments (for non-obvious logic)?

## 4. Test Coverage
- Are critical paths tested?
- Are edge cases covered?

Conclude with a summary identifying the 1-3 most important issues and improvement suggestions.
Enter fullscreen mode Exit fullscreen mode

$ARGUMENTS is a special placeholder — arguments the user passes when invoking the Skill replace this placeholder. If the Skill body doesn't contain $ARGUMENTS, the user's arguments are appended to the end of the body.

The frontmatter has only two required fields:

  • name: the Skill's unique identifier, can only contain lowercase letters, digits, and hyphens (a-z0-9-)
  • description: a one-sentence description that gets injected into the system prompt, letting the agent know this Skill is available

II. How Skills Are Discovered

SkillLoader in extensions/skills/loader.py is responsible for scanning and caching Skills:

# extensions/skills/loader.py
class SkillLoader:
    def __init__(self, project_root: str, skills_dir: str = "skills"):
        self._skills_dir = (Path(project_root) / skills_dir).resolve()
        self._skills: Dict[str, SkillMeta] = {}
        self._last_scan_mtime: float = 0.0  # max mtime of all SKILL.md files at last scan
        self._last_scan_count: int = 0      # number of files at last scan

    def refresh_if_stale(self) -> List[SkillMeta]:
        current_max_mtime, current_count = self._get_skills_state()
        if current_max_mtime != self._last_scan_mtime or current_count != self._last_scan_count:
            return self.scan()   # files changed → re-scan
        return self.list_skills(refresh=False)  # no change → return cache directly
Enter fullscreen mode Exit fullscreen mode

The caching strategy is clever: instead of re-scanning file contents every time, it only makes stat() calls to compare two numbers — the maximum mtime of all SKILL.md files and the file count.

  • If a file was modified (mtime changed) → re-scan
  • If a file was added or deleted (count changed) → re-scan
  • Otherwise return the in-memory cache directly

The overhead of stat() is orders of magnitude smaller than reading file contents, so this strategy makes the cost of "checking whether an update is needed" nearly zero, while still ensuring changes take effect immediately when a file is modified.


III. How description Gets Injected into the System Prompt

SkillLoader also has a method:

def format_skills_for_prompt(self, char_budget: int) -> str:
    """Format the SkillMeta list as text to inject into the system prompt."""
    ...
    # Output format:
    # - code-review: Perform a code review on the specified file following team standards
    # - gen-commit-msg: Generate a commit message in Conventional Commits format
Enter fullscreen mode Exit fullscreen mode

This text gets injected into the "Skills" section of the system prompt, roughly like this:

## Available Skills
The following project-specific skills are available via the Skill tool:
- code-review: Perform a code review on the specified file following team standards
- gen-commit-msg: Generate a commit message in Conventional Commits format
Enter fullscreen mode Exit fullscreen mode

Note: only the name and description are injected here, not the Skill's full body. The full body is only read and returned when the agent actually calls Skill(name="code-review").

This design saves tokens: the system prompt only has a one-line summary, and won't bloat the context window just because there are many Skills. The char_budget parameter controls the maximum total length of the summaries (default 12000 characters); Skills exceeding the budget are truncated and not shown.


IV. What Happens When the Agent Calls a Skill

When the agent decides to call Skill(name="code-review", args="src/main.py"), SkillTool.run() does these things:

# tools/builtin/skill.py
def run(self, parameters):
    name = parameters.get("name")
    args = parameters.get("args") or ""

    # 1. Get SkillMeta from loader (includes file path)
    skill_meta = self._skill_loader.get_skill(name.strip(), refresh=self._refresh_on_call)

    # 2. Read the full content of SKILL.md
    raw_content = Path(skill_meta.path).read_text(encoding="utf-8")

    # 3. Parse frontmatter, get the body
    _frontmatter, body = _parse_frontmatter(raw_content)

    # 4. Fill args into the $ARGUMENTS placeholder (or append to end)
    expanded = _apply_arguments(body, args)

    # 5. Return the fully expanded instructions
    return self.success_result(
        data={"content": expanded, "name": name, "base_dir": skill_meta.base_dir},
        text=f"Loaded skill '{name}'.",
        ...
    )
Enter fullscreen mode Exit fullscreen mode

The _apply_arguments logic:

def _apply_arguments(body: str, args: str) -> str:
    if "$ARGUMENTS" in body:
        return body.replace("$ARGUMENTS", args)   # has placeholder → replace in-place
    if args.strip():
        return f"{body}\n\nARGUMENTS: {args}"      # no placeholder → append
    return body                                     # no args → return as-is
Enter fullscreen mode Exit fullscreen mode

After receiving the Skill's return value, the agent treats the expanded instructions as the "operations manual" for the current task.


V. Writing an Actual Skill

Now let's write a Skill: generating a commit message that follows the Conventional Commits specification.

Directory structure:

skills/
└── gen-commit-msg/
    └── SKILL.md
Enter fullscreen mode Exit fullscreen mode

SKILL.md content:

---
name: gen-commit-msg
description: Generate a Conventional Commits-compliant commit message from the current git diff
---

Based on the following git diff content, generate a commit message that follows the Conventional Commits specification.

$ARGUMENTS

## Specification Requirements

**Format**: `<type>(<scope>): <description>`

**type values**:
- `feat`: new feature
- `fix`: bug fix
- `refactor`: refactoring (code changes that don't affect functionality)
- `docs`: documentation changes
- `test`: test-related
- `chore`: build, configuration, dependency changes

**Requirements**:
- description should be concise and clear, no more than 50 characters
- if the change spans multiple modules, scope can be omitted
- output only the commit message itself, no additional explanation

**Examples**:
Enter fullscreen mode Exit fullscreen mode

feat(auth): add JWT token refresh mechanism
fix(tools): fix boundary error in Read tool when handling empty files
refactor(context): split HistoryManager into History and ModelView

Enter fullscreen mode Exit fullscreen mode

How users invoke it:

# In conversation:
Run git diff --staged with bash first, then use the gen-commit-msg skill to generate a commit message
Enter fullscreen mode Exit fullscreen mode


plaintext

The agent will:

  1. Call Bash(command="git diff --staged") to get the diff
  2. Call Skill(name="gen-commit-msg", args="<diff content>") to load instructions
  3. Generate a commit message according to the Skill's specification

VI. Criteria for Choosing Between Skills and Tools

When should you write a Skill, and when should you write a tool?

Use a Skill when:

  • You want the agent to handle a class of tasks with a specific methodology (code review, writing docs, generating tests, etc.)
  • This methodology is described in natural language and doesn't need to execute concrete code
  • You want this behavior to be quickly modifiable and iterable without changing Python code

Use a Tool when:

  • You need the agent to be able to execute a specific action (call an API, read/write files, run commands)
  • The action's result is structured data that needs to be parsed by subsequent steps
  • You need system-level guarantees like sandboxing, timeout control, and error codes

Simply put: Skills change "how to think," tools change "what can be done."


Design Highlights

1. Zero-code extension

Only a Markdown file is needed — no Python, no service restart. This lets non-engineers (product, design, operations) add domain expertise to the agent.

2. Hot reload

The incremental check mechanism of refresh_if_stale() (only doing stat comparisons) ensures Skill file changes are immediately detected without requiring a restart. This is very useful when debugging and iterating on Skills.

3. Load body on demand

Only names and description summaries are injected into the system prompt; the Skill's full body is only read when called. This means you can install many Skills without worrying about bloating the context window.

4. $ARGUMENTS placeholder

Allows Skill authors to precisely control where arguments appear in the instructions, rather than always appending to the end. This lets the "preamble" and "arguments" in a Skill naturally blend together.


Summary

Design Choice Approach Engineering Value
Format Markdown frontmatter + body Human-readable, no parser needed
Caching mtime + count incremental check File changes take effect immediately, stat overhead is nearly zero
Injection strategy System prompt only gets summary Multiple Skills don't bloat context
Argument injection $ARGUMENTS placeholder Author controls argument position, more flexible than appending

The next article is the last one: connecting MCP services, linking the agent to the external tool ecosystem.


About the Source Code for This Series

All analysis in this series is based on the open source project MyCodeAgent.

The source code already has companion comments added at key locations in the order covered by this series — you can read alongside the code, or clone it directly to run, modify, and extend it to build your own agent.

git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env   # fill in your LLM API key
uv sync
uv run python main.py
Enter fullscreen mode Exit fullscreen mode

Visit PrimeSkills — a carefully curated AI Agent and skills marketplace where every piece of content is validated through real enterprise-grade workflows. No hype, only what actually works.

For more practical knowledge and interesting products, visit my personal homepage

Top comments (0)