Core Concepts
Progressive disclosure
An Agent Skill is a directory of instructions and supporting files. The point of the format is that an agent should not carry all of it in context all of the time. Skills load in three levels:
| Level | What the model gets | When | Provided by |
|---|---|---|---|
| 1. Metadata | Each skill's name and description | Always in the prompt | harness Skills |
| 2. Instructions | The SKILL.md body |
When the model calls load_capability |
harness Skills |
| 3. Bundled files | references/, assets/, scripts/ |
When the model calls read_skill_resource or run_skill_script |
this package |
Level 1 costs a line or two per skill, so a hundred skills stay affordable. Level 2 costs a page, and only for the skills a task actually touches. Level 3 costs nothing until a specific file is needed.
Who does what
SkillsCapability is a composite. It owns the parts of the pipeline harness does not:
registries ──sync()──► local directories ─┐
├──► harness Skills ──► one deferred
directories ──────────────────────────────┘ Capability per skill
│
index the same directories ──► bundled files ──► read_skill_resource ◄──┘
run_skill_script
- Registries are synced. Each
SkillRegistrymaterializes its skills into a local directory. A Git registry clones, an S3 registry downloads, a composed registry stages a new directory from its children's output. - harness discovers and validates. The synced directories and any local ones are handed to
pydantic_ai_harness.Skills, which scans each library's immediate children, validates theSKILL.mdfrontmatter, appliesinclude/exclude, and produces one deferredCapabilityper skill. - Bundled files are indexed. The same directories are scanned for each skill's resources and scripts, keyed by the skill's name — the same name harness gave the capability.
- The catalog is re-emitted. Each capability harness produced is passed through, with
${SKILL_DIR}resolved in its instructions when the skill has a directory, and its bundled files listed at the end of them.
Everything happens at construction time. See Snapshots below.
Deferred capabilities, not a load_skill tool
Every skill is a deferred capability: the model sees its id and description in a catalog, and
loads it by calling Pydantic AI's built-in load_capability tool. v1 of this package shipped its
own list_skills and load_skill tools; both are gone, because the framework now does it.
That matters beyond tidiness. Because a skill is a real capability, its instructions participate in
Pydantic AI's instruction ordering, message history records what was loaded, and
RunContext.active_capability_ids knows which skills are live — which is what the
require_loaded gate reads.
The two tools this package adds
Both take the skill's name as their first argument, so one pair of tools serves every skill:
read_skill_resource(skill_name, resource_name, args=None)— reads a bundled text file, named by its path relative to the skill directory (references/FORMS.md). For a programmatic skill it can also invoke a callable resource.run_skill_script(skill_name, script_name, args=None)— runs a bundled script (scripts/fill_form.py) through the configured script executor, and returns its output.
They are always visible, while each skill stays deferred. Per-skill toolsets were the obvious alternative and do not work: two loaded skills would contribute two tools with the same name.
require_loaded
Because the tools are always visible, something has to stop the model reading a skill's files
without loading the skill. require_loaded=True (the default) checks
RunContext.active_capability_ids and refuses with a ModelRetry that names the capability to load:
Skill 'pdf-processing' is not loaded. Call load_capability with id='pdf-processing' first,
then read its files.
Set require_loaded=False when a skill's files should be reachable without loading its
instructions first.
Bundled-file inventory
Both tools resolve names against the index, whose keys are skill-relative paths
(scripts/aggregate.py). A SKILL.md, though, usually names its own files in prose — "run the
aggregate script" — so a model calling run_skill_script has nothing exact to copy and guesses.
SkillsCapability closes that gap by appending the package's real names to the skill's
instructions:
## Bundled files
Read with `read_skill_resource`, using these exact `resource_name` values:
- `references/NOTES.md`
Run with `run_skill_script`, using these exact `script_name` values:
- `scripts/aggregate.py`
Because it rides on the instructions, it stays behind load_capability: the model pays for the
listing only once it has loaded that skill, never in the always-on catalog. Long packages are
truncated after 50 entries per kind.
Pass list_bundled_files=False for skills whose SKILL.md already lists its files.
As a fallback, both tools also accept an unambiguous shorthand: aggregate or aggregate.py
resolves to scripts/aggregate.py when exactly one indexed name matches. Two files sharing a name
resolve to neither — the ModelRetry names both candidates and asks for the full path.
${SKILL_DIR}
Published skills often write paths as ${SKILL_DIR}/scripts/run.py or ${CLAUDE_SKILL_DIR}/....
harness passes those through untouched, which leaves the model holding a literal placeholder.
SkillsCapability substitutes the skill's real directory when it re-emits the instructions:
SkillsCapability('.agents/skills', resolve_skill_dir=True) # the default
Pass resolve_skill_dir=False to get exactly what harness rendered.
Skill packages
A skill is an immediate child of a library directory containing a SKILL.md:
.agents/skills/ ← the library (what you pass)
├── pdf-processing/ ← a skill package
│ ├── SKILL.md
│ ├── FORMS.md
│ ├── references/
│ │ └── LAYOUT.md
│ └── scripts/
│ └── fill_form.py
└── data-analysis/
└── SKILL.md
Pass the library, not a skill package. Nesting is not searched: pdf-processing/vendor/SKILL.md
does not become a second skill. This mirrors harness exactly, so the indexed files and the catalog
can never disagree about what a skill is.
See Creating Skills for the SKILL.md format and how resources and scripts
are discovered.
Snapshots
Discovery runs once, when SkillsCapability is constructed — registries sync, harness scans,
files are indexed. Nothing re-reads the filesystem during a run.
v1 had reload() and auto_reload; both are gone. To pick up changes, build a new capability and a
new agent:
def build_agent() -> Agent:
return Agent('anthropic:claude-sonnet-4-6', capabilities=[SkillsCapability('./skills')])
# On a redeploy, or on a schedule:
agent = build_agent()
This is harness's model too, and it is the honest one: an agent's tools and instructions are fixed for the run, so a mid-run reload was never coherent.
Where skills come from
| Source | Use | Docs |
|---|---|---|
| A local directory | Skills committed alongside your application | Creating Skills |
GitSkillsRegistry |
Skills published in a repository | Registries |
S3SkillsRegistry |
Skills distributed through object storage | Registries |
LocalSkillsRegistry |
A local directory that needs composing | Registries |
skills=[...] |
Skills defined in Python | Programmatic Skills |
They all land in one catalog. Names must be unique across every source, since each becomes a
capability id; prefixed() resolves collisions.