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:
-
Readtool: read the filesrc/main.pyfor me -
code-reviewSkill: 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.
$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
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
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
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}'.",
...
)
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
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
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**:
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
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
plaintext
The agent will:
- Call
Bash(command="git diff --staged")to get the diff - Call
Skill(name="gen-commit-msg", args="<diff content>")to load instructions - 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
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)