Skip to content

Advanced Features

Selecting which skills to expose

A skill directory grows. Not every agent should see everything in it.

from pydantic_ai_skills import SkillsCapability

# Only these
SkillsCapability('./skills', include=['pdf-processing', 'data-analysis'])

# Everything except these
SkillsCapability('./skills', exclude=['experimental', 'internal-debug'])

# Nothing
SkillsCapability('./skills', include=[])
Configuration Skills in the catalog
Neither All discovered skills
include=['a', 'b'] Only a and b
include=[] No skills
exclude=['a', 'b'] All except a and b

The two cannot be combined, and a name matching nothing raises at construction — so a typo fails where you can see it, rather than silently narrowing the catalog.

Selection covers every source: include=['my-python-skill'] works for a skill passed via skills= just as it does for one on disk.

This is catalog exposure, not access control

exclude keeps a skill out of the prompt. It is not a filesystem permission and not a security boundary — the files are still on disk and still readable by anything else in the process. See Security.

Selection by source

To give different agents different subsets of the same registry, filter the registry rather than the capability:

from pydantic_ai_skills import GitSkillsRegistry, SkillsCapability

source = GitSkillsRegistry('https://github.com/anthropics/skills', path='skills')

docs_agent = SkillsCapability(registries=[source.filtered(lambda info: 'doc' in info.description)])
data_agent = SkillsCapability(registries=[source.filtered(lambda info: 'data' in info.name)])

See Registries for the full composition story.

Picking up changes to skills

Discovery is a snapshot taken at construction. There is no reload(), and no auto_reload — v1 had both, and neither was coherent: an agent's instructions and tools are fixed for a run.

Rebuild instead:

from pydantic_ai import Agent
from pydantic_ai_skills import SkillsCapability


def build_agent() -> Agent:
    return Agent('anthropic:claude-sonnet-4-6', capabilities=[SkillsCapability('./skills')])


agent = build_agent()

For a long-lived server, rebuild on a redeploy, or on a schedule, and swap the reference:

import asyncio
from contextlib import asynccontextmanager

from fastapi import FastAPI

state: dict[str, Agent] = {}


async def refresh_hourly() -> None:
    while True:
        await asyncio.sleep(3600)
        state['agent'] = build_agent()  # registries re-sync here


@asynccontextmanager
async def lifespan(app: FastAPI):
    state['agent'] = build_agent()
    task = asyncio.create_task(refresh_hourly())
    yield
    task.cancel()


app = FastAPI(lifespan=lifespan)

Rebuilding also re-syncs every registry, which is how you pick up new skills published upstream. Note that a registry's sync() does real work — a git pull, an S3 listing — so rebuild on a schedule you would be happy to see in your egress logs.

Skills defined in Python

The @skill decorator builds a skill from a function returning its instructions:

from pydantic_ai import Agent, RunContext
from pydantic_ai_skills import SkillsCapability, skill


@skill(metadata={'version': '1.0.0'}, license='MIT')
def analytics() -> str:
    """Analyze application data and generate reports."""
    return """
    Use this skill for questions about usage and revenue.
    Read the `schema` resource before writing a query, then run `report`.
    """


@analytics.resource
async def schema(ctx: RunContext[AppDeps]) -> str:
    """The current warehouse schema."""
    return await ctx.deps.database.get_schema()


@analytics.script
async def report(ctx: RunContext[AppDeps], period: str = 'week') -> str:
    """Generate a usage report for a period."""
    return await ctx.deps.database.generate_report(period)


agent = Agent(
    'openai:gpt-5.2',
    deps_type=AppDeps,
    capabilities=[SkillsCapability(skills=[analytics])],
)

The name comes from the function (analytics), the description from its docstring. Both can be overridden with name= and description=.

Because these are ordinary Python functions, they get dependency injection, type checking, and direct unit testing — see Programmatic Skills.

Dependency injection via RunContext

Any resource or script on a programmatic skill can take RunContext as its first parameter and reach the agent's dependencies:

from dataclasses import dataclass

from pydantic_ai import Agent, RunContext
from pydantic_ai_skills import Skill, SkillsCapability


@dataclass
class AppDeps:
    database: DatabaseConn
    tenant_id: str


tenant_data = Skill(
    name='tenant-data',
    description='Query the current tenant\'s records.',
    content='Read `summary` first, then run `query` with a SQL string.',
)


@tenant_data.resource
async def summary(ctx: RunContext[AppDeps]) -> str:
    """A summary of the tenant's data."""
    return await ctx.deps.database.summarize(ctx.deps.tenant_id)


@tenant_data.script
async def query(ctx: RunContext[AppDeps], sql: str) -> str:
    """Run a read-only query scoped to the tenant."""
    return str(await ctx.deps.database.execute(sql, tenant=ctx.deps.tenant_id))


agent = Agent(
    'openai:gpt-5.2',
    deps_type=AppDeps,
    capabilities=[SkillsCapability(skills=[tenant_data])],
)
result = await agent.run('How many records do we have?', deps=AppDeps(db, tenant_id='acme'))

Scoping by ctx.deps is the right way to keep one tenant's data away from another's — far better than hoping the model respects an instruction saying so.

File-based scripts do not receive RunContext; they run as subprocesses. To pass request-scoped values to them, see environment variables.

Custom script executors

run_skill_script routes every file-based script through a SkillScriptExecutor. The protocol is structural — one async def run(script, args=None, ctx=None) — so anything matching it works:

from typing import Any

from pydantic_ai_skills import SkillScript, SkillsCapability


class RemoteExecutor:
    """Run scripts on a worker instead of the agent host."""

    def __init__(self, endpoint: str) -> None:
        self.endpoint = endpoint

    async def run(
        self,
        script: SkillScript,
        args: dict[str, Any] | None = None,
        ctx: Any | None = None,
    ) -> str:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                self.endpoint,
                json={'uri': script.uri, 'args': args or {}},
                timeout=60,
            )
            return response.text


capability = SkillsCapability('./skills', script_executor=RemoteExecutor('https://worker/run'))

One executor covers every skill the capability exposes, wherever it came from. In v1 the executor lived on each source, which made it easy to add a registry and quietly fall back to host execution.

Two executors ship with the package for keeping untrusted scripts off the host — see Sandboxing. CallableSkillScriptExecutor wraps a plain callable, which is useful for tests:

from pydantic_ai_skills import CallableSkillScriptExecutor

recorded = []


def fake_run(script, args=None, ctx=None):
    recorded.append((script.name, args))
    return 'stubbed output'


capability = SkillsCapability('./skills', script_executor=CallableSkillScriptExecutor(fake_run))

Turning off the file tools

SkillsCapability('./skills', scripts=False)                     # no run_skill_script
SkillsCapability('./skills', resources=False)                   # no read_skill_resource
SkillsCapability('./skills', resources=False, scripts=False)    # neither

With both off this behaves like harness Skills on its own — at which point, use harness directly.

Prefer a sandbox executor over scripts=False when the problem is trust rather than capability: a skill whose instructions describe running a script it cannot run tends to make the model improvise.

Both tools are also omitted automatically when no skill ships files of that kind, so a capability over instructions-only skills adds nothing to the model's tool list.

Gating file access on the loaded skill

By default the file tools refuse a skill the model has not loaded:

Skill 'pdf-processing' is not loaded. Call load_capability with id='pdf-processing' first,
then read its files.

That keeps bundled files behind the same boundary as the skill's instructions. Turn it off when a skill's files should be reachable regardless:

SkillsCapability('./skills', require_loaded=False)

This reads RunContext.active_capability_ids, which is refreshed from message history before each request — so a skill loaded in an earlier step is visible, and only a call issued in the same step as the load is refused, with a retry the model can act on.

Listing a skill's bundled files

The file tools key on skill-relative paths (scripts/aggregate.py), while a SKILL.md names its own files however its author wrote the prose. SkillsCapability appends the real names to the skill's instructions so the model reads them instead of guessing:

SkillsCapability('./skills')                              # inventory appended (the default)
SkillsCapability('./skills', list_bundled_files=False)    # instructions as harness rendered them

The listing rides on the instructions, so it stays behind load_capability and costs nothing for skills the model never loads. Only kinds whose tool is registered are listed — scripts=False drops the script block — and each kind is truncated after 50 entries.

Independently of the listing, both tools accept an unambiguous shorthand: aggregate and aggregate.py both reach scripts/aggregate.py. When two files share a name, neither is chosen; the retry names both and asks for the full path. See Bundled-file inventory.

Resolving ${SKILL_DIR}

Published skill packages often write paths as ${SKILL_DIR}/scripts/run.py or ${CLAUDE_SKILL_DIR}/.... harness passes the placeholder through untouched. SkillsCapability substitutes the skill's real directory:

SkillsCapability('./skills')                            # resolved (the default)
SkillsCapability('./skills', resolve_skill_dir=False)   # exactly what harness rendered

Turn it off when you want byte-identical instructions to a plain Skills setup, or when the literal placeholder means something to your own tooling.

Skill metadata

Extra SKILL.md frontmatter keys are accepted, but nothing in the runtime reads them — harness acts only on name and description.

---
name: pdf-processing
description: Fill and extract PDF forms.
version: 2.1.0
owner: platform-team
---

Treat those as documentation for your own tooling. In particular, the behavioural fields other Agent Skills clients define — allowed-tools, model, hooks, disable-model-invocation and friends — are accepted and inert; harness warns about them at construction. A skill relying on allowed-tools to restrict itself is not restricted here.

For metadata the runtime does act on, use programmatic skills, where metadata= is yours to read back.

Mixed sources

Local directories, registries, and Python-defined skills all land in one catalog:

from pydantic_ai_skills import GitSkillsRegistry, S3SkillsRegistry, SkillsCapability, skill


@skill
def runtime_config() -> str:
    """Read the deployment's runtime configuration."""
    return 'Consult the deployment config before answering environment questions.'


capability = SkillsCapability(
    './skills',                                    # committed alongside the app
    registries=[
        GitSkillsRegistry(                         # published upstream
            'https://github.com/anthropics/skills',
            path='skills',
        ).prefixed('anthropic-'),
        S3SkillsRegistry(bucket='acme-skills'),    # distributed internally
    ],
    skills=[runtime_config],                       # defined in Python
)

Names must be unique across all of them, since each becomes a capability id. A programmatic skill shadowing a directory-backed one wins with a warning; two registries colliding are resolved by CombinedRegistry or by prefixing, as above.

See also