Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

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.

Four folders feed a session's skills: the project source loads .claude/skills in the working directory and in each added directory, the user source loads the same folder in the home directory, and the plugins option loads a plugin's own skills folder without any setting source; the skills option then filters which of the discovered skills the Skill tool may invoke, while the init message's skills array lists every skill that was found.

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.

Example.claude/skills/refund-policy/SKILL.md
---
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

python
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 bare Skill tool to the allowed tools.
  • A list adds one Skill(name) rule for each entry, so the others are refused.
  • Either way, an unset setting_sources becomes user,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.

Example
try:
    flags(ClaudeAgentOptions(skills=["shop:*"]))
except ValueError as error:
    print(error)

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.

Exampleshop_desk.py, a new method
    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
Exampleshop_desk.py, two more methods
    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.

Exampleshop_desk.py, two more again
    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])
Exampleshop_desk.py, the last new method
    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()
Exampleshop_desk.py, the end of start
        # 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)})
Exampleshop_desk.py, the new branch inside use
        # 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:
Exampleshop_desk.py, the new branch inside ShopModel.reply
    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})]}
Exampleshop_desk.py, the end of __init__
        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

python
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)
Example
await watch("What is the refund policy?", desk(["refund-policy"]))
Example
await watch("What is the refund policy?", desk([]))
  • 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 Skill tool 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.

Example
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"])

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.

Example
from shop_desk import ShopModel

script = [{"calls": [("Skill", {"name": "refund-policy"})]}]
await watch("What is the refund policy?", desk(["order-status"]), ShopModel(script))

The four values skills takes

ValueWhat Claude may invokeUse it when
Left outEvery skill the session discoveredYou want the CLI's behaviour
"all"Every skill the session discoveredYou are saying so in the code, and want the setting sources filled in
A list of namesOnly those namesA service that should offer a fixed set
[]None of themSkills 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.
Watch out. Restricting the list does not take the files away. They stay on disk and stay reachable through Read and Bash, and sending /refund-policy as a prompt runs the skill whatever the list says.
Try it yourself
  • 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" with setting_sources=[] and read the init array.

You understood something today that you didn't yesterday.