Skills
A skill is a folder holding a SKILL.md file of instructions that Claude loads when a request matches its description. The skills option decides which of the skills a session found Claude is allowed to invoke.
Last updated: 20 Oct, 2026 · Claude Agent SDK
Setting sources decide which folders a session reads. Skills are the first thing worth putting in those folders, because a skill is a piece of your own knowledge that Claude picks up only when it is needed, instead of sitting in the prompt all session.
There is no Python call that registers a skill. A skill is a file on disk, and the SDK finds it: skills.
Writing a SKILL.md the session can find
Each skill is its own folder with a SKILL.md inside. The frontmatter names it and describes when to use it, and the description is what Claude reads to decide. The body is the instruction.
---
name: refund-policy
description: The shop's refund rules. Use when a customer asks what can be refunded.
---
Say exactly: Refunds run for 14 days from delivery, minus return postage.Say exactly: is not a rule of the format. It is a line the desk below knows how to obey, so that the file's effect on the answer is visible. A real session hands the whole body to Claude as instructions.
What the skills option becomes on the command line
from claude_agent_sdk import ClaudeAgentOptions
from flags import flags
# the Skill tool and the setting sources are both filled in for you here
print(flags(ClaudeAgentOptions(skills="all")))
print(flags(ClaudeAgentOptions(skills=["refund-policy"], setting_sources=["project"])))"all"adds the bareSkilltool to the allowed tools.- A list adds one
Skill(name)rule for each entry, so the others are refused. - Either way, an unset
setting_sourcesbecomesuser,project, because a session with no sources would find no skills to allow.
The list takes exact names only. A wildcard is refused before the process starts, so a rule that could never match fails loudly rather than granting nothing.
try:
flags(ClaudeAgentOptions(skills=["shop:*"]))
except ValueError as error:
print(error)Invalid skill name 'shop:*': wildcard-suffix names are not allowed; list each skill by its exact name.
Teaching the desk to find a skill
shop_desk.py gains five methods and start is replaced so that the init message carries what was found. roots is the list of folders, and it follows the same table the documentation gives for the sources.
def roots(self):
"""The folders a skill can come from, each with the prefix its names carry."""
found = []
if "project" in self.sources():
# cwd plus every add_dirs entry, which the SDK hands over as --add-dir
where = [self.options.cwd or "."] + list(self.options.add_dirs)
found += [(Path(d) / ".claude", "") for d in where]
if "user" in self.sources():
found.append((Path.home() / ".claude", ""))
return found def find_skills(self):
"""name -> SKILL.md text, for every skill folder this session loads."""
return {prefix + path.parent.name: path.read_text()
for root, prefix in self.roots()
for path in sorted(root.glob("skills/*/SKILL.md"))}
def permitted(self):
"""The skills Claude may invoke: the skills option filters what was discovered."""
# the one place the option decides anything; discovery above it is untouched
chosen = self.options.skills
if chosen is None or chosen == "all":
return dict(self.found)
return {name: body for name, body in self.found.items() if name in chosen}The folder name is the skill's name, which is why the dictionary is keyed on path.parent.name. permitted is where the option does its work: unset or "all" keeps everything, a list keeps what it names, and an empty list keeps none.
def offered(self):
"""What the model may call: the MCP tools, plus Skill when a skill is permitted."""
return list(self.tools) + (["Skill"] if self.permitted() else [])
def run_skill(self, name):
# a skill the list leaves out is refused here, not hidden from the filesystem
if name not in self.permitted():
return f"Skill {name} is not in this session's skills allowlist"
return self.follow(self.found[name]) def follow(self, body, args=""):
"""The desk obeys one instruction shape; a real model reads the whole body."""
# everything after the frontmatter is the instruction
text = body.split("---", 2)[-1].strip()
for line in text.splitlines():
if line.lower().startswith("say exactly:"):
text = line.split(":", 1)[1].strip()
break
return text.replace("$ARGUMENTS", args).strip() # discovery happens at session start, which is why the init message can list it
self.found = self.find_skills()
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": list(self.tools),
"skills": sorted(self.found)}) # Skill reads a file this session already loaded, so no server is called for it
if name == "Skill":
text, failed = self.run_skill(args["name"]), False
else: def reply(self, said, results, tools=(), rules="", skills=()):
...
# the stand-in matches a skill on the words in its name, where Claude reads its description
for name in skills:
if any(word in said[-1].lower() for word in name.split(":")[-1].split("-")):
return {"calls": [("Skill", {"name": name})]} self.tools, self.failed, self.ready = {}, None, False
self.found, self.loaded = {}, []think now hands the model self.offered() and self.permitted() in place of the raw tool list. self.loaded is filled in by the plugins lesson and stays empty until then.
Two runs, one allowlist apart
from claude_agent_sdk import ClaudeAgentOptions
from shop_tools import shop
from watch import watch
def desk(skills):
# the project source only, so the session's skills are the ones in this folder
return ClaudeAgentOptions(mcp_servers={"shop": shop},
setting_sources=["project"], skills=skills)await watch("What is the refund policy?", desk(["refund-policy"]))system init
assistant calls Skill {'name': 'refund-policy'}
user result: Refunds run for 14 days from delivery, minus return postage.
assistant Refunds run for 14 days from delivery, minus return postage.
result success: Refunds run for 14 days from delivery, minus return postage.await watch("What is the refund policy?", desk([]))system init assistant Which order is this about? result success: Which order is this about?
- With the name on the list the desk calls
Skill, the file is read, and its line comes back as the answer. - With an empty list there is no
Skilltool to call, so the desk falls back on its ordinary reply. - The file never moved. Both runs had it on disk; only the option changed.
Checking what loaded before the work starts
The init message carries a skills array. It lists every skill the session discovered, whether or not your list allows it, which makes it the place to look when a skill seems missing.
from claude_agent_sdk import SystemMessage, query
from shop_desk import ShopDesk
options = desk(["refund-policy"])
async for message in query(prompt="Hello", options=options, transport=ShopDesk(options)):
if isinstance(message, SystemMessage) and message.subtype == "init":
print(message.data["skills"])['refund-policy']
Asking for a skill the list leaves out gets a refusal from the Skill tool rather than silence. Here the desk's model is handed a script so that it asks for the skill anyway.
from shop_desk import ShopModel
script = [{"calls": [("Skill", {"name": "refund-policy"})]}]
await watch("What is the refund policy?", desk(["order-status"]), ShopModel(script))system init
assistant calls Skill {'name': 'refund-policy'}
user result: Skill refund-policy is not in this session's skills allowlist
assistant Skill refund-policy is not in this session's skills allowlist
result success: Skill refund-policy is not in this session's skills allowlistThe four values skills takes
| Value | What Claude may invoke | Use it when |
|---|---|---|
| Left out | Every skill the session discovered | You want the CLI's behaviour |
"all" | Every skill the session discovered | You are saying so in the code, and want the setting sources filled in |
| A list of names | Only those names | A service that should offer a fixed set |
[] | None of them | Skills are off for this run |
Where a skill earns its place
- A policy or a format that only matters for some questions, so it should not sit in the prompt all session.
- A workflow a person also runs from the CLI, since the same folder serves both.
- A fixed list of skills for a service, so a new file on the machine cannot change what the agent can do.
Read and Bash, and sending /refund-policy as a prompt runs the skill whatever the list says.Related
- Previous: Claude Code features in the SDK
- Next: Commands
- Reference: Extend agents with skills
- Change the
Say exactly:line in the file and run the first example again. - Add a second skill folder,
order-status, and pass both names. - Pass
skills="all"withsetting_sources=[]and read the init array.
You understood something today that you didn't yesterday.