Skip to content

SkillsCapability API Reference

SkillsCapability is the entry point. Pass it to an agent's capabilities=[...].

It is a composite over pydantic-ai-harness's Skills: harness discovers and validates the skill packages and renders their instructions, while this capability syncs remote registries, indexes bundled files, resolves ${SKILL_DIR}, and adds Python-defined skills. See Core Concepts for the division of labour.

Bases: AbstractCapability[AgentDepsT]

Expose Agent Skills — local, remote, or Python-defined — to a Pydantic AI agent.

Every skill becomes its own deferred capability: the model sees names and descriptions up front and pulls a skill's instructions in with load_capability. Skills discovered on disk are validated and rendered by pydantic-ai-harness; this capability adds remote sources, the bundled-file tools, and programmatic skills on top.

Discovery is a snapshot taken during construction, matching harness's own semantics. Call registry.sync() and build a new SkillsCapability to pick up changes.

Example
from pydantic_ai import Agent
from pydantic_ai_skills import GitSkillsRegistry, SkillsCapability

agent = Agent(
    'anthropic:claude-sonnet-4-6',
    capabilities=[
        SkillsCapability(
            '.agents/skills',
            registries=[
                GitSkillsRegistry(
                    'https://github.com/anthropics/skills',
                    path='skills',
                ),
            ],
        ),
    ],
)
Source code in pydantic_ai_skills/capability.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
@dataclass(init=False, repr=False)
class SkillsCapability(AbstractCapability[AgentDepsT]):
    """Expose Agent Skills — local, remote, or Python-defined — to a Pydantic AI agent.

    Every skill becomes its own deferred capability: the model sees names and descriptions
    up front and pulls a skill's instructions in with `load_capability`. Skills discovered
    on disk are validated and rendered by `pydantic-ai-harness`; this capability adds
    remote sources, the bundled-file tools, and programmatic skills on top.

    Discovery is a snapshot taken during construction, matching harness's own semantics.
    Call `registry.sync()` and build a new `SkillsCapability` to pick up changes.

    Example:
        ```python
        from pydantic_ai import Agent
        from pydantic_ai_skills import GitSkillsRegistry, SkillsCapability

        agent = Agent(
            'anthropic:claude-sonnet-4-6',
            capabilities=[
                SkillsCapability(
                    '.agents/skills',
                    registries=[
                        GitSkillsRegistry(
                            'https://github.com/anthropics/skills',
                            path='skills',
                        ),
                    ],
                ),
            ],
        )
        ```
    """

    directories: tuple[str | Path, ...]
    """Skill-library paths scanned during construction."""

    registries: tuple[SkillRegistry, ...]
    """Registries synced to local libraries during construction."""

    include: frozenset[str] | None
    """Exact skill names to expose, or `None` to expose all discovered skills."""

    exclude: frozenset[str]
    """Exact skill names to omit from the catalog."""

    _skills: Skills[AgentDepsT] | None = field(init=False, repr=False, compare=False)
    _packages: dict[str, SkillPackage] = field(init=False, repr=False, compare=False)
    _leaves: tuple[AbstractCapability[AgentDepsT], ...] = field(init=False, repr=False, compare=False)
    _files_toolset: SkillFilesToolset | None = field(init=False, repr=False, compare=False)
    _list_resources: bool = field(init=False, repr=False, compare=False)
    _list_scripts: bool = field(init=False, repr=False, compare=False)

    def __init__(
        self,
        directories: str | Path | Sequence[str | Path] = (),
        *,
        registries: Sequence[SkillRegistry] = (),
        skills: Sequence[Skill | SkillWrapper[Any]] = (),
        include: Collection[str] | None = None,
        exclude: Collection[str] | None = None,
        script_executor: SkillScriptExecutor | None = None,
        exclude_resources: Sequence[str] | None = None,
        resources: bool = True,
        scripts: bool = True,
        require_loaded: bool = True,
        resolve_skill_dir: bool = True,
        list_bundled_files: bool = True,
        id: str | None = None,
    ) -> None:
        """Build the deferred catalog from libraries, registries and Python-defined skills.

        Args:
            directories: One skill-library path or a sequence of them. A library is the
                *parent* of the skill packages, not a skill package itself.
            registries: Registries to sync into local libraries before discovery. See
                [`SkillRegistry`][pydantic_ai_skills.SkillRegistry].
            skills: Python-defined skills, which join the same deferred catalog.
            include: Exact names to expose. Omit to expose all discovered skills.
            exclude: Exact names to omit. Cannot be combined with `include`.
            script_executor: Executor for bundled scripts. Defaults to
                [`LocalSkillScriptExecutor`][pydantic_ai_skills.LocalSkillScriptExecutor],
                which runs them as subprocesses on the host. Pass a sandbox executor for
                skills from sources you do not fully trust.
            exclude_resources: Extra glob patterns to exclude from resource discovery, on
                top of
                [`DEFAULT_RESOURCE_EXCLUDES`][pydantic_ai_skills.packages.DEFAULT_RESOURCE_EXCLUDES].
            resources: Register the `read_skill_resource` tool.
            scripts: Register the `run_skill_script` tool.
            require_loaded: Refuse bundled-file calls for a skill the model has not loaded,
                keeping files behind the same boundary as the skill's instructions.
            resolve_skill_dir: Substitute `${SKILL_DIR}` and `${CLAUDE_SKILL_DIR}` in a
                skill's instructions with its real directory, so instructions that name
                those placeholders resolve to paths the script tool can actually use.
            list_bundled_files: Append a "Bundled files" section naming a skill's
                resources and scripts to its instructions, so the model reads the names
                the file tools expect instead of inferring them from prose. Turn off for
                skills whose `SKILL.md` already lists its files.
            id: Stable identifier for the capability that carries the bundled-file tools.

        Raises:
            ValueError: If `include` and `exclude` are combined, if no source is
                configured, or if `include`/`exclude` name a skill that does not exist.
        """
        if include is not None and exclude is not None:
            raise ValueError('include and exclude cannot be used together.')

        self.id = id
        self.directories = self._normalize_directories(directories)
        self.registries = tuple(registries)
        self.include = _normalize_selection('include', include) if include is not None else None
        self.exclude = _normalize_selection('exclude', exclude) if exclude is not None else frozenset()
        # Only list what the model can actually reach: a kind whose tool is not registered
        # has no name worth advertising.
        self._list_resources = resources and list_bundled_files
        self._list_scripts = scripts and list_bundled_files

        programmatic = [entry.to_skill() if isinstance(entry, SkillWrapper) else entry for entry in skills]

        if not self.directories and not self.registries and not programmatic:
            raise ValueError(
                'SkillsCapability needs at least one source: a skill-library directory, a registry, or a skill.'
            )

        libraries: list[str | Path] = [*self.directories]
        libraries.extend(registry.sync() for registry in self.registries)

        # Index bundled files first: the directory-backed names it finds are what lets us
        # split `include`/`exclude` between harness (which rejects names it does not know)
        # and the programmatic skills harness never sees.
        self._packages = index_libraries(
            libraries,
            script_executor=script_executor,
            exclude_resources=exclude_resources,
        )
        directory_names = frozenset(self._packages)
        programmatic_names = frozenset(_normalize(skill.name) for skill in programmatic)
        self._validate_selection(directory_names | programmatic_names)

        self._skills = self._build_harness_skills(libraries, directory_names)

        selected_programmatic = self._resolve_duplicates(
            [skill for skill in programmatic if self._is_selected(_normalize(skill.name))]
        )
        shadowed = {_normalize(skill.name) for skill in selected_programmatic}

        leaves: list[AbstractCapability[AgentDepsT]] = []
        if self._skills is not None:
            harness_leaves: list[AbstractCapability[AgentDepsT]] = []
            self._skills.apply(harness_leaves.append)
            leaves.extend(
                self._rebuild_leaf(leaf, resolve_skill_dir) for leaf in harness_leaves if leaf.id not in shadowed
            )

        for skill in selected_programmatic:
            name = _normalize(skill.name)
            if name in self._packages:
                warnings.warn(
                    f"Programmatic skill '{name}' shadows a skill of the same name discovered on disk. "
                    'The programmatic definition wins; rename one of them to expose both.',
                    UserWarning,
                    stacklevel=2,
                )
            package = SkillPackage(
                name=name,
                resources=tuple(skill.resources),
                scripts=tuple(skill.scripts),
            )
            self._packages[name] = package

            instructions = f'# Skill: {name}\n\n{skill.content}' if skill.content else f'# Skill: {name}'
            inventory = _bundled_files_section(package, resources=self._list_resources, scripts=self._list_scripts)
            if inventory is not None:
                instructions = f'{instructions}\n\n{inventory}'

            leaves.append(
                Capability[AgentDepsT](
                    id=name,
                    description=skill.description,
                    instructions=instructions,
                    defer_loading=True,
                )
            )

        self._files_toolset = self._build_files_toolset(
            resources=resources,
            scripts=scripts,
            require_loaded=require_loaded,
        )
        self._leaves = tuple(leaves)

    def __repr__(self) -> str:
        """Show only the configuration the caller controls."""
        return (
            f'{type(self).__name__}('
            f'directories={self.directories!r}, registries={self.registries!r}, '
            f'include={self.include!r}, exclude={self.exclude!r})'
        )

    # ------------------------------------------------------------------
    # Construction helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _normalize_directories(directories: str | Path | Sequence[str | Path]) -> tuple[str | Path, ...]:
        if isinstance(directories, (str, Path)):
            return (directories,)
        return tuple(directories)

    @staticmethod
    def _resolve_duplicates(skills: Sequence[Skill]) -> list[Skill]:
        """Collapse programmatic skills sharing a name, keeping the last and warning.

        Two skills under one name would produce two deferred capabilities with the same
        id, which pydantic-ai rejects at run setup with an error that says nothing about
        where the collision came from.
        """
        by_name: dict[str, Skill] = {}
        for skill in skills:
            name = _normalize(skill.name)
            if name in by_name:
                warnings.warn(
                    f"Duplicate skill '{name}' found in `skills`; the last definition wins.",
                    UserWarning,
                    stacklevel=3,
                )
            by_name[name] = skill
        return list(by_name.values())

    def _is_selected(self, name: str) -> bool:
        """Apply `include`/`exclude` to a single skill name."""
        if self.include is not None:
            return name in self.include
        return name not in self.exclude

    def _validate_selection(self, available: frozenset[str]) -> None:
        """Reject `include`/`exclude` names that match no skill from any source.

        harness performs this check for its own libraries, but it never sees programmatic
        skills — so a selection naming one would look unknown to it. Validating across
        both sources here means the error message lists everything actually available.

        Raises:
            ValueError: When a selected name matches no known skill.
        """
        for option, selected in (('include', self.include), ('exclude', self.exclude)):
            if selected is None:
                continue
            unknown = sorted(selected - available)
            if not unknown:
                continue
            noun = 'skill' if len(unknown) == 1 else 'skills'
            available_text = ', '.join(sorted(available)) or '(none)'
            raise ValueError(f'Unknown {noun} in {option}: {", ".join(unknown)}. Available skills: {available_text}.')

    def _build_harness_skills(
        self,
        libraries: Sequence[str | Path],
        directory_names: frozenset[str],
    ) -> Skills[AgentDepsT] | None:
        """Construct the harness `Skills` that owns discovery and instruction rendering.

        The selection is narrowed to names harness can actually see: it raises on an
        `include` naming a skill it did not discover, and a selection may legitimately
        refer to a programmatic skill instead.
        """
        if not libraries:
            return None

        if self.include is not None:
            return Skills[AgentDepsT](libraries, include=sorted(self.include & directory_names))
        return Skills[AgentDepsT](libraries, exclude=sorted(self.exclude & directory_names))

    def _rebuild_leaf(
        self,
        leaf: AbstractCapability[AgentDepsT],
        resolve_skill_dir: bool,
    ) -> AbstractCapability[AgentDepsT]:
        """Return `leaf` with placeholders resolved and its bundled files listed.

        harness emits plain-string instructions, so when there is nothing to substitute and
        nothing to list the original leaf is handed back untouched rather than rebuilt.
        """
        package = self._packages.get(leaf.id) if leaf.id else None
        if package is None:
            return leaf

        instructions = leaf.get_instructions()
        if not isinstance(instructions, list) or not all(isinstance(part, str) for part in instructions):
            return leaf

        resolved = list(instructions)
        if resolve_skill_dir and package.directory is not None:
            resolved = [_resolve_placeholders(part, package.directory) for part in resolved]

        inventory = _bundled_files_section(package, resources=self._list_resources, scripts=self._list_scripts)
        if inventory is not None:
            resolved.append(inventory)

        if resolved == instructions:
            return leaf

        return Capability[AgentDepsT](
            id=leaf.id,
            description=leaf.get_description(),
            instructions=resolved,
            defer_loading=True,
        )

    def _build_files_toolset(
        self,
        *,
        resources: bool,
        scripts: bool,
        require_loaded: bool,
    ) -> SkillFilesToolset | None:
        """Build the bundled-file toolset, or None when it would expose no tools."""
        has_resources = resources and any(package.resources for package in self._packages.values())
        has_scripts = scripts and any(package.scripts for package in self._packages.values())
        if not has_resources and not has_scripts:
            return None

        return SkillFilesToolset(
            self._packages,
            resources=has_resources,
            scripts=has_scripts,
            require_loaded=require_loaded,
            id=self.id,
        )

    # ------------------------------------------------------------------
    # Public surface
    # ------------------------------------------------------------------

    @property
    def skill_names(self) -> list[str]:
        """Names of the skills exposed to the model, sorted."""
        return sorted(leaf.id for leaf in self._leaves if leaf.id)

    @property
    def packages(self) -> dict[str, SkillPackage]:
        """The indexed bundled files, keyed by skill name."""
        return self._packages

    def apply(self, visitor: Callable[[AbstractCapability[AgentDepsT]], None]) -> None:
        """Visit this capability and each skill it exposes as a deferred leaf.

        Unlike a pure container, this visits `self` as well. It has to: pydantic-ai builds
        the run's capability registry from `apply`, and the toolset returned by
        `get_toolset` is owned by whichever capability that registry maps it to. Skipping
        `self` leaves the bundled-file tools with no registered owner, which fails the run
        the first time the model calls one.

        Only visited when this capability actually contributes a toolset, so a
        `SkillsCapability` over skills that ship no files stays a pure container.
        """
        if self._files_toolset is not None:
            visitor(self)
        for leaf in self._leaves:
            leaf.apply(visitor)

    def visit_and_replace(
        self,
        visitor: Callable[[AbstractCapability[AgentDepsT]], AbstractCapability[AgentDepsT] | None],
    ) -> AbstractCapability[AgentDepsT] | None:
        """Rewrite the leaves in place, keeping this capability as their container."""
        replaced: list[AbstractCapability[AgentDepsT]] = []
        changed = False
        for leaf in self._leaves:
            result = leaf.visit_and_replace(visitor)
            if result is not leaf:
                changed = True
            if result is not None:
                replaced.append(result)

        if not changed:
            return self
        if not replaced:
            return None

        clone = object.__new__(type(self))
        clone.__dict__.update(self.__dict__)
        clone._leaves = tuple(replaced)
        return clone

    def get_toolset(self) -> AgentToolset[AgentDepsT] | None:
        """Return the bundled-file toolset, or None when no skill ships files.

        This has to come from the container itself rather than from a leaf: pydantic-ai
        collects toolsets by calling `get_toolset()` on a container's direct children
        (see `CombinedCapability.get_toolset`), and does not recurse through `apply` the
        way it does when building the capability-id registry. A toolset parked on a leaf
        would never be registered.

        The tools stay always-on while each skill is deferred, which is deliberate: the
        model needs them the moment it loads a skill, and `require_loaded` — not tool
        visibility — is what keeps a skill's files behind its instructions.
        """
        return self._files_toolset

    @classmethod
    def get_serialization_name(cls) -> str | None:
        """Return the name used to reference this capability in agent specs."""
        return 'SkillsCapability'

    @classmethod
    def from_spec(
        cls,
        *,
        directories: list[str] | str | None = None,
        include: list[str] | None = None,
        exclude: list[str] | None = None,
        exclude_resources: list[str] | None = None,
        resources: bool = True,
        scripts: bool = True,
        require_loaded: bool = True,
        resolve_skill_dir: bool = True,
        list_bundled_files: bool = True,
        id: str | None = None,
    ) -> AbstractCapability[Any]:
        """Create from a YAML/JSON agent spec.

        Only serializable arguments are supported. Registries, programmatic skills, and
        custom script executors cannot be expressed in a spec; construct the capability in
        Python for those.

        Args:
            directories: Skill-library paths, as strings.
            include: Exact skill names to expose. Cannot be combined with `exclude`.
            exclude: Exact skill names to omit. Cannot be combined with `include`.
            exclude_resources: Extra glob patterns to exclude from resource discovery.
            resources: Register the `read_skill_resource` tool.
            scripts: Register the `run_skill_script` tool.
            require_loaded: Refuse bundled-file calls for a skill that is not loaded.
            resolve_skill_dir: Substitute `${SKILL_DIR}` / `${CLAUDE_SKILL_DIR}` in
                instructions with the skill's directory.
            list_bundled_files: Append a "Bundled files" section naming a skill's
                resources and scripts to its instructions.
            id: Stable identifier for the capability carrying the bundled-file tools.
        """
        return cls(
            directories=directories if directories is not None else (),
            include=include,
            exclude=exclude,
            exclude_resources=exclude_resources,
            resources=resources,
            scripts=scripts,
            require_loaded=require_loaded,
            resolve_skill_dir=resolve_skill_dir,
            list_bundled_files=list_bundled_files,
            id=id,
        )

__init__

__init__(directories: str | Path | Sequence[str | Path] = (), *, registries: Sequence[SkillRegistry] = (), skills: Sequence[Skill | SkillWrapper[Any]] = (), include: Collection[str] | None = None, exclude: Collection[str] | None = None, script_executor: SkillScriptExecutor | None = None, exclude_resources: Sequence[str] | None = None, resources: bool = True, scripts: bool = True, require_loaded: bool = True, resolve_skill_dir: bool = True, list_bundled_files: bool = True, id: str | None = None) -> None

Build the deferred catalog from libraries, registries and Python-defined skills.

Parameters:

Name Type Description Default
directories str | Path | Sequence[str | Path]

One skill-library path or a sequence of them. A library is the parent of the skill packages, not a skill package itself.

()
registries Sequence[SkillRegistry]

Registries to sync into local libraries before discovery. See SkillRegistry.

()
skills Sequence[Skill | SkillWrapper[Any]]

Python-defined skills, which join the same deferred catalog.

()
include Collection[str] | None

Exact names to expose. Omit to expose all discovered skills.

None
exclude Collection[str] | None

Exact names to omit. Cannot be combined with include.

None
script_executor SkillScriptExecutor | None

Executor for bundled scripts. Defaults to LocalSkillScriptExecutor, which runs them as subprocesses on the host. Pass a sandbox executor for skills from sources you do not fully trust.

None
exclude_resources Sequence[str] | None

Extra glob patterns to exclude from resource discovery, on top of DEFAULT_RESOURCE_EXCLUDES.

None
resources bool

Register the read_skill_resource tool.

True
scripts bool

Register the run_skill_script tool.

True
require_loaded bool

Refuse bundled-file calls for a skill the model has not loaded, keeping files behind the same boundary as the skill's instructions.

True
resolve_skill_dir bool

Substitute ${SKILL_DIR} and ${CLAUDE_SKILL_DIR} in a skill's instructions with its real directory, so instructions that name those placeholders resolve to paths the script tool can actually use.

True
list_bundled_files bool

Append a "Bundled files" section naming a skill's resources and scripts to its instructions, so the model reads the names the file tools expect instead of inferring them from prose. Turn off for skills whose SKILL.md already lists its files.

True
id str | None

Stable identifier for the capability that carries the bundled-file tools.

None

Raises:

Type Description
ValueError

If include and exclude are combined, if no source is configured, or if include/exclude name a skill that does not exist.

Source code in pydantic_ai_skills/capability.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def __init__(
    self,
    directories: str | Path | Sequence[str | Path] = (),
    *,
    registries: Sequence[SkillRegistry] = (),
    skills: Sequence[Skill | SkillWrapper[Any]] = (),
    include: Collection[str] | None = None,
    exclude: Collection[str] | None = None,
    script_executor: SkillScriptExecutor | None = None,
    exclude_resources: Sequence[str] | None = None,
    resources: bool = True,
    scripts: bool = True,
    require_loaded: bool = True,
    resolve_skill_dir: bool = True,
    list_bundled_files: bool = True,
    id: str | None = None,
) -> None:
    """Build the deferred catalog from libraries, registries and Python-defined skills.

    Args:
        directories: One skill-library path or a sequence of them. A library is the
            *parent* of the skill packages, not a skill package itself.
        registries: Registries to sync into local libraries before discovery. See
            [`SkillRegistry`][pydantic_ai_skills.SkillRegistry].
        skills: Python-defined skills, which join the same deferred catalog.
        include: Exact names to expose. Omit to expose all discovered skills.
        exclude: Exact names to omit. Cannot be combined with `include`.
        script_executor: Executor for bundled scripts. Defaults to
            [`LocalSkillScriptExecutor`][pydantic_ai_skills.LocalSkillScriptExecutor],
            which runs them as subprocesses on the host. Pass a sandbox executor for
            skills from sources you do not fully trust.
        exclude_resources: Extra glob patterns to exclude from resource discovery, on
            top of
            [`DEFAULT_RESOURCE_EXCLUDES`][pydantic_ai_skills.packages.DEFAULT_RESOURCE_EXCLUDES].
        resources: Register the `read_skill_resource` tool.
        scripts: Register the `run_skill_script` tool.
        require_loaded: Refuse bundled-file calls for a skill the model has not loaded,
            keeping files behind the same boundary as the skill's instructions.
        resolve_skill_dir: Substitute `${SKILL_DIR}` and `${CLAUDE_SKILL_DIR}` in a
            skill's instructions with its real directory, so instructions that name
            those placeholders resolve to paths the script tool can actually use.
        list_bundled_files: Append a "Bundled files" section naming a skill's
            resources and scripts to its instructions, so the model reads the names
            the file tools expect instead of inferring them from prose. Turn off for
            skills whose `SKILL.md` already lists its files.
        id: Stable identifier for the capability that carries the bundled-file tools.

    Raises:
        ValueError: If `include` and `exclude` are combined, if no source is
            configured, or if `include`/`exclude` name a skill that does not exist.
    """
    if include is not None and exclude is not None:
        raise ValueError('include and exclude cannot be used together.')

    self.id = id
    self.directories = self._normalize_directories(directories)
    self.registries = tuple(registries)
    self.include = _normalize_selection('include', include) if include is not None else None
    self.exclude = _normalize_selection('exclude', exclude) if exclude is not None else frozenset()
    # Only list what the model can actually reach: a kind whose tool is not registered
    # has no name worth advertising.
    self._list_resources = resources and list_bundled_files
    self._list_scripts = scripts and list_bundled_files

    programmatic = [entry.to_skill() if isinstance(entry, SkillWrapper) else entry for entry in skills]

    if not self.directories and not self.registries and not programmatic:
        raise ValueError(
            'SkillsCapability needs at least one source: a skill-library directory, a registry, or a skill.'
        )

    libraries: list[str | Path] = [*self.directories]
    libraries.extend(registry.sync() for registry in self.registries)

    # Index bundled files first: the directory-backed names it finds are what lets us
    # split `include`/`exclude` between harness (which rejects names it does not know)
    # and the programmatic skills harness never sees.
    self._packages = index_libraries(
        libraries,
        script_executor=script_executor,
        exclude_resources=exclude_resources,
    )
    directory_names = frozenset(self._packages)
    programmatic_names = frozenset(_normalize(skill.name) for skill in programmatic)
    self._validate_selection(directory_names | programmatic_names)

    self._skills = self._build_harness_skills(libraries, directory_names)

    selected_programmatic = self._resolve_duplicates(
        [skill for skill in programmatic if self._is_selected(_normalize(skill.name))]
    )
    shadowed = {_normalize(skill.name) for skill in selected_programmatic}

    leaves: list[AbstractCapability[AgentDepsT]] = []
    if self._skills is not None:
        harness_leaves: list[AbstractCapability[AgentDepsT]] = []
        self._skills.apply(harness_leaves.append)
        leaves.extend(
            self._rebuild_leaf(leaf, resolve_skill_dir) for leaf in harness_leaves if leaf.id not in shadowed
        )

    for skill in selected_programmatic:
        name = _normalize(skill.name)
        if name in self._packages:
            warnings.warn(
                f"Programmatic skill '{name}' shadows a skill of the same name discovered on disk. "
                'The programmatic definition wins; rename one of them to expose both.',
                UserWarning,
                stacklevel=2,
            )
        package = SkillPackage(
            name=name,
            resources=tuple(skill.resources),
            scripts=tuple(skill.scripts),
        )
        self._packages[name] = package

        instructions = f'# Skill: {name}\n\n{skill.content}' if skill.content else f'# Skill: {name}'
        inventory = _bundled_files_section(package, resources=self._list_resources, scripts=self._list_scripts)
        if inventory is not None:
            instructions = f'{instructions}\n\n{inventory}'

        leaves.append(
            Capability[AgentDepsT](
                id=name,
                description=skill.description,
                instructions=instructions,
                defer_loading=True,
            )
        )

    self._files_toolset = self._build_files_toolset(
        resources=resources,
        scripts=scripts,
        require_loaded=require_loaded,
    )
    self._leaves = tuple(leaves)

skill_names property

skill_names: list[str]

Names of the skills exposed to the model, sorted.

packages property

packages: dict[str, SkillPackage]

The indexed bundled files, keyed by skill name.

apply

apply(visitor: Callable[[AbstractCapability[AgentDepsT]], None]) -> None

Visit this capability and each skill it exposes as a deferred leaf.

Unlike a pure container, this visits self as well. It has to: pydantic-ai builds the run's capability registry from apply, and the toolset returned by get_toolset is owned by whichever capability that registry maps it to. Skipping self leaves the bundled-file tools with no registered owner, which fails the run the first time the model calls one.

Only visited when this capability actually contributes a toolset, so a SkillsCapability over skills that ship no files stays a pure container.

Source code in pydantic_ai_skills/capability.py
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def apply(self, visitor: Callable[[AbstractCapability[AgentDepsT]], None]) -> None:
    """Visit this capability and each skill it exposes as a deferred leaf.

    Unlike a pure container, this visits `self` as well. It has to: pydantic-ai builds
    the run's capability registry from `apply`, and the toolset returned by
    `get_toolset` is owned by whichever capability that registry maps it to. Skipping
    `self` leaves the bundled-file tools with no registered owner, which fails the run
    the first time the model calls one.

    Only visited when this capability actually contributes a toolset, so a
    `SkillsCapability` over skills that ship no files stays a pure container.
    """
    if self._files_toolset is not None:
        visitor(self)
    for leaf in self._leaves:
        leaf.apply(visitor)

visit_and_replace

visit_and_replace(visitor: Callable[[AbstractCapability[AgentDepsT]], AbstractCapability[AgentDepsT] | None]) -> AbstractCapability[AgentDepsT] | None

Rewrite the leaves in place, keeping this capability as their container.

Source code in pydantic_ai_skills/capability.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def visit_and_replace(
    self,
    visitor: Callable[[AbstractCapability[AgentDepsT]], AbstractCapability[AgentDepsT] | None],
) -> AbstractCapability[AgentDepsT] | None:
    """Rewrite the leaves in place, keeping this capability as their container."""
    replaced: list[AbstractCapability[AgentDepsT]] = []
    changed = False
    for leaf in self._leaves:
        result = leaf.visit_and_replace(visitor)
        if result is not leaf:
            changed = True
        if result is not None:
            replaced.append(result)

    if not changed:
        return self
    if not replaced:
        return None

    clone = object.__new__(type(self))
    clone.__dict__.update(self.__dict__)
    clone._leaves = tuple(replaced)
    return clone

get_toolset

get_toolset() -> AgentToolset[AgentDepsT] | None

Return the bundled-file toolset, or None when no skill ships files.

This has to come from the container itself rather than from a leaf: pydantic-ai collects toolsets by calling get_toolset() on a container's direct children (see CombinedCapability.get_toolset), and does not recurse through apply the way it does when building the capability-id registry. A toolset parked on a leaf would never be registered.

The tools stay always-on while each skill is deferred, which is deliberate: the model needs them the moment it loads a skill, and require_loaded — not tool visibility — is what keeps a skill's files behind its instructions.

Source code in pydantic_ai_skills/capability.py
518
519
520
521
522
523
524
525
526
527
528
529
530
531
def get_toolset(self) -> AgentToolset[AgentDepsT] | None:
    """Return the bundled-file toolset, or None when no skill ships files.

    This has to come from the container itself rather than from a leaf: pydantic-ai
    collects toolsets by calling `get_toolset()` on a container's direct children
    (see `CombinedCapability.get_toolset`), and does not recurse through `apply` the
    way it does when building the capability-id registry. A toolset parked on a leaf
    would never be registered.

    The tools stay always-on while each skill is deferred, which is deliberate: the
    model needs them the moment it loads a skill, and `require_loaded` — not tool
    visibility — is what keeps a skill's files behind its instructions.
    """
    return self._files_toolset

from_spec classmethod

from_spec(*, directories: list[str] | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, exclude_resources: list[str] | None = None, resources: bool = True, scripts: bool = True, require_loaded: bool = True, resolve_skill_dir: bool = True, list_bundled_files: bool = True, id: str | None = None) -> AbstractCapability[Any]

Create from a YAML/JSON agent spec.

Only serializable arguments are supported. Registries, programmatic skills, and custom script executors cannot be expressed in a spec; construct the capability in Python for those.

Parameters:

Name Type Description Default
directories list[str] | str | None

Skill-library paths, as strings.

None
include list[str] | None

Exact skill names to expose. Cannot be combined with exclude.

None
exclude list[str] | None

Exact skill names to omit. Cannot be combined with include.

None
exclude_resources list[str] | None

Extra glob patterns to exclude from resource discovery.

None
resources bool

Register the read_skill_resource tool.

True
scripts bool

Register the run_skill_script tool.

True
require_loaded bool

Refuse bundled-file calls for a skill that is not loaded.

True
resolve_skill_dir bool

Substitute ${SKILL_DIR} / ${CLAUDE_SKILL_DIR} in instructions with the skill's directory.

True
list_bundled_files bool

Append a "Bundled files" section naming a skill's resources and scripts to its instructions.

True
id str | None

Stable identifier for the capability carrying the bundled-file tools.

None
Source code in pydantic_ai_skills/capability.py
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
@classmethod
def from_spec(
    cls,
    *,
    directories: list[str] | str | None = None,
    include: list[str] | None = None,
    exclude: list[str] | None = None,
    exclude_resources: list[str] | None = None,
    resources: bool = True,
    scripts: bool = True,
    require_loaded: bool = True,
    resolve_skill_dir: bool = True,
    list_bundled_files: bool = True,
    id: str | None = None,
) -> AbstractCapability[Any]:
    """Create from a YAML/JSON agent spec.

    Only serializable arguments are supported. Registries, programmatic skills, and
    custom script executors cannot be expressed in a spec; construct the capability in
    Python for those.

    Args:
        directories: Skill-library paths, as strings.
        include: Exact skill names to expose. Cannot be combined with `exclude`.
        exclude: Exact skill names to omit. Cannot be combined with `include`.
        exclude_resources: Extra glob patterns to exclude from resource discovery.
        resources: Register the `read_skill_resource` tool.
        scripts: Register the `run_skill_script` tool.
        require_loaded: Refuse bundled-file calls for a skill that is not loaded.
        resolve_skill_dir: Substitute `${SKILL_DIR}` / `${CLAUDE_SKILL_DIR}` in
            instructions with the skill's directory.
        list_bundled_files: Append a "Bundled files" section naming a skill's
            resources and scripts to its instructions.
        id: Stable identifier for the capability carrying the bundled-file tools.
    """
    return cls(
        directories=directories if directories is not None else (),
        include=include,
        exclude=exclude,
        exclude_resources=exclude_resources,
        resources=resources,
        scripts=scripts,
        require_loaded=require_loaded,
        resolve_skill_dir=resolve_skill_dir,
        list_bundled_files=list_bundled_files,
        id=id,
    )

get_serialization_name classmethod

get_serialization_name() -> str | None

Return the name used to reference this capability in agent specs.

Source code in pydantic_ai_skills/capability.py
533
534
535
536
@classmethod
def get_serialization_name(cls) -> str | None:
    """Return the name used to reference this capability in agent specs."""
    return 'SkillsCapability'

Constructor parameters

Parameter Type Default Description
directories str \| Path \| Sequence[str \| Path] () Skill-library paths. A library is the parent of the skill packages.
registries Sequence[SkillRegistry] () Remote sources, synced to local libraries at construction.
skills Sequence[Skill \| SkillWrapper] () Skills defined in Python.
include Collection[str] \| None None Exact names to expose. Cannot be combined with exclude.
exclude Collection[str] \| None None Exact names to omit. Cannot be combined with include.
script_executor SkillScriptExecutor \| None None Where bundled scripts run. Defaults to local subprocesses.
exclude_resources Sequence[str] \| None None Extra glob patterns excluded from resource discovery.
resources bool True Register read_skill_resource.
scripts bool True Register run_skill_script.
require_loaded bool True Refuse bundled-file calls for a skill the model has not loaded.
resolve_skill_dir bool True Substitute ${SKILL_DIR} / ${CLAUDE_SKILL_DIR} in instructions.
id str \| None None Stable identifier for the capability carrying the file tools.

At least one of directories, registries or skills must be given.

Tools

SkillsCapability registers two tools. The catalog and instruction loading are Pydantic AI's own load_capability, not something this package provides.

Tool Signature Purpose
read_skill_resource (skill_name, resource_name, args=None) Read a bundled text file, or invoke a callable resource.
run_skill_script (skill_name, script_name, args=None) Execute a bundled script through the configured executor.

Both are omitted entirely when no skill ships files of the matching kind.

Agent specs

SkillsCapability works with Pydantic AI's YAML and JSON agent specs:

model: anthropic:claude-sonnet-4-6
capabilities:
  - SkillsCapability:
      directories: ['./skills']
      include: ['pdf-processing']
      scripts: false
agent = Agent.from_file('agent.yaml', custom_capability_types=[SkillsCapability])

Registries, programmatic skills and custom executors cannot be expressed in a spec — construct the capability in Python for those.