Three Design Pitfalls in Agent Skill Systems (and How to Avoid Them)
Foreword
What is a Skill? Simply put, it is a skill pack, with a SKILL.md file at its core. When an Agent is working, it loads the corresponding Skill on demand based on the task's needs—for example, if it needs to do UI design or code review, it loads the relevant one.
Sounds pretty simple, right? You might have already opened an AI programming tool, ready to throw in a prompt like "Help me implement a Skill mechanism."
Wait, hold on. Skill sounds simple, but have you really thought through how to implement it in an Agent system? See if you can answer these questions first:
- Besides
nameanddescription, what other header metadata is in SKILL.md? - When an Agent wants to use a Skill, how does it locate and invoke the Skill?
- Have you considered Skill deduplication?
If you haven't figured out these questions yet, then I'll break them down one by one and explain the design of the entire Skill mechanism clearly.
1. Understanding SKILL.md: Header Metadata
Please first check the standard Skill header fields officially provided by Claude Code: https://code.claude.com/docs/en/skills Here, I'll pick a few typical ones to talk about:
| Field | Function |
|---|---|
name |
Display name |
description |
Shown to the model, determines when to use it |
allowed-tools |
Tool whitelist |
disallowed-tools |
Tool blacklist |
model |
Restricts the model |
metadata |
Custom key-value pairs |
agent |
Specifies the subagent for execution |
context |
Whether to open an independent sub-context during execution |
Among them,
nameanddescriptiondetermine when the Agent calls it.allowed-toolsmight be understood by many as only allowing the use of certain tools, but it actually grants the Agent permission to execute these tools without you needing to watch and approve.disallowed-toolsis literal—it rigidly prohibits certain tools/commands, even if the model wants to call them, the system will intercept. There's a pitfall here: the tools disabled this time need to be restored in the next turn, otherwise these tools will remain disabled for the entire session, affecting subsequent operations.
(What a turn is will be explained in a later article; for now, understand it like this: a turn is a session round.)
[Turn 1] You send a message → Model calls a Skill → Skill becomes active
→ Tools in disallowed-tools are removed from the available tool pool
→ In this turn, the model simply "cannot see" Write / Edit, unable to call them even if it wants to
[Turn 2] You send the next message → Restrictions cleared → Write / Edit become available again
disallowed-tools, likeallowed-tools, has a temporary scope. It is only pre-approved for the turn in which the Skill is called.modelcan specify that this Skill is only enabled under a specific model; other models cannot load it. There are two typical uses: if a Skill relies on long context or complex reasoning that a small model can't handle, restrict it to only use a large model to avoid crashes; conversely, simple Skills can also be restricted to small models to save money.contextandagentare used together:contextdetermines whether this Skill runs in the main conversation or opens an independent sub-context to run separately, andagentdetermines which sub-agent executes it. A typical scenario is: a Skill process is very messy—reading dozens of files, running a bunch of commands—but you only care about the final result. At this point, setcontext: fork, let it run in an independent context without polluting the main conversation, and then assign a sub-agent to do this heavy lifting.metadatais a place to stuff any custom key-value pairs; the model generally doesn't read it. It's usually for team management, auditing, and cost accounting—for example, recordingowner,tags,cost-center. When the Skill management platform scans directories, it can categorize and count based on these fields. It doesn't affect how the Agent runs, only how you manage it.
Skill Example
A simple scenario, which is also the header metadata for most Skills:
---
name: frontend-ui-engineering
description: Build production-quality user interfaces. Use when building or modifying user-facing interfaces. Use when creating components, implementing layouts, managing state, or when the output needs to look production-quality rather than "AI-generated".
---
A slightly more complex one:
---
name: code-review
description: Use when the user asks to review code, check PRs, or find potential bugs.
allowed-tools:
- Read
- Grep
- Bash(git diff:*)
model: claude-sonnet-4-5
disable-model-invocation: false
license: MIT
version: 1.2.0
metadata:
scope: project
agents: [backend-bot, reviewer-bot]
---
(license, disable-model-invocation, version in the example are other fields in the official standard; you can ignore them for a basic version first.)
Conclusion: If you are implementing a basic Skill system, only handling name and description is enough; to expand later, build on top of these fields.
2. Deep Dive into Skill and Agent Interaction
1. How the Agent Invokes a Skill
How does the Agent invoke a Skill? There are currently two mainstream approaches:
- Design a dedicated tool for Skills, where the Agent loads the Skill via
skill_name
SKILL_TOOL = {
"name": "skill",
"description": "Load a registered Skill by name and return its complete instructions.",
"parameters": {
"skill_name": {
"type": "string",
"description": "The name of the Skill to load",
"required": True,
},
},
}
def execute_skill_tool(skill_name: str) -> str:
skill = find_skill_by_name(skill_name) # Find by name in the registry
if not skill:
return f"Skill not found: {skill_name}"
body = load_body(skill["path"]) # Main body
apply_permissions(skill["meta"]) # Apply allowed/disallowed-tools
# Return a three-piece set, with the body injected into context as a tool result
return {
"activation": f"<command-name>{skill_name}</command-name>", # Activation marker
"base_dir": skill["base_dir"], # Root directory, relative paths in the body rely on it
"body": body, # Body instructions
}
Note that this return value is not just the content of the Skill.md file, but three things:
- activation (Activation Marker):
<command-name>{skill_name}</command-name>. This is for the system to see—indicating "this Skill has been called", used for deduplication and state tracking (explained below), and also convenient for the frontend to display call events. - base_dir (Root Directory): The directory where the Skill is located.
- body (Main Body): The main content of SKILL.md.
- Read the SKILL.md body by path using the ReadFile tool
READ_FILE_TOOL = {
"name": "read_file",
"description": "Read file content by path.",
"parameters": {
"path": {
"type": "string",
"description": "File path",
"required": True,
},
},
}
def execute_read_file_tool(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
SKILL_TOOLcan utilize all metadata fields—fine-grained permissions, execution context, activation markers—suitable for complex Skill systems;ReadFilecan only read the full text, simple, enough for the early stages.
A little extra knowledge: A Skill's composition is not just the SKILL.md file. Besides SKILL.md, it can also include knowledge documents, executable scripts, static resources, etc.:
{skill_name}/
├── SKILL.md # Required: Entry point (YAML frontmatter + Markdown instructions)
├── scripts/ # Optional: Executable scripts (Python / Bash)
├── references/ # Optional: Long documents, specifications, examples
└── assets/ # Optional: Templates, icons, fonts, and other static resources
So the essence of invoking a Skill is actually a tool call: use a read tool to read SKILL.md or other knowledge files, and use the Bash tool to execute scripts.
However, in Codex, I noticed it internally uses PowerShell's cat command to get SKILL.md.
Claude Code tends to use SKILL_TOOL to complete Skill loading.
2. How the Agent Locates a Skill
The first step is for the system to scan a specified directory, find all SKILL.md files under skill folders, read only the header's name and description, and collect them into a registry.
def register_skills(skill_dir: str) -> list[dict]:
skills = []
for path in Path(skill_dir).rglob("SKILL.md"):
meta = parse_frontmatter(path) # Only read header name and description
skills.append({
"name": meta["name"],
"description": meta["description"],
"path": str(path),
})
return skills
Then this information is injected into the system prompt. The injected content differs for the two tools:
- Using the SKILL_TOOL method: Only inject
nameanddescription - Using the ReadFile method: Besides
nameanddescription, also inject the path of SKILL.md so the model knows where to read it
The display of Skills in the context roughly looks like this:
<available_skills>
<skill>
<name>pdf</name>
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, merging/splitting documents, and handling forms.</description>
<path>/absolute/path/to/pdf/SKILL.md</path>
</skill>
</available_skills>
(<path> is for the ReadFile method to locate the file; if using the Skill tool method, the path stays in the registry and is not exposed to the model.)
Note that only name and description are placed here, not the body. This is the most critical design in the entire Skill system: registration must be lightweight. If the body were stuffed in at this step, the subsequent "matching" and "loading" would be meaningless, and the context would be filled with irrelevant content.
3. Skill Activation Markers: Deduplication and State Tracking
Deduplication: Don't Load the Same Skill Repeatedly
In a single task, the model might repeatedly want to use the same Skill. For example, if a user asks twice in a row "review the code for me again", the model might want to call code-review both times.
Without deduplication, the body of code-review would be injected twice, wasting tokens and adding duplicate content to the context.
With activation markers + deduplication, the system can judge:
code-reviewhas already been activated, don't inject it again this time, just reuse it.
In one sentence: Deduplication = preventing the body of the same Skill from being repeatedly stuffed into the context.
State Tracking: Remember "Which Skills Are Currently Active"
The system needs to maintain a "currently active Skills list" because several things rely on it:
- Permissions: While a Skill is active, its allowed/disallowed-tools take effect; once it exits activation, the permissions must be revoked. The system needs to know "whose permissions should be revoked now".
- Frontend Display: The interface shows "currently executing code-review", which is also read from this list.
- Lifecycle: Record when a Skill enters active and when it exits.
In one sentence: State tracking = the system maintains a table of "who is currently active" to manage permission activation and restoration.
In your Skill system implementation, it roughly looks like:
active_skills = set() # Set of currently active Skills
def activate(skill_name):
if skill_name in active_skills:
return "Already active, skipping" # Deduplication
active_skills.add(skill_name) # State tracking
apply_permissions(skill)
def deactivate(skill_name):
active_skills.discard(skill_name)
restore_permissions()
The active_skills set simultaneously handles both "deduplication" and "state tracking"—it's used to judge duplicates and to know what to restore.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
I bow to you, master.