Skip to content

Implementation Patterns

Recipes for common situations. Each one states the problem first, so you can tell whether it is yours.

Skill selection

Give each agent only what it needs

A shared skill directory serves several agents, but each should see a subset — a hundred catalog entries in the prompt is a hundred entries the model has to read past.

from pydantic_ai import Agent
from pydantic_ai_skills import SkillsCapability

support = Agent(
    'openai:gpt-5.2',
    capabilities=[SkillsCapability('./skills', include=['refunds', 'order-lookup'])],
)
research = Agent(
    'openai:gpt-5.2',
    capabilities=[SkillsCapability('./skills', include=['arxiv-search', 'web-research'])],
)

An include name matching nothing raises at construction, so a renamed skill breaks the build rather than silently shrinking an agent's catalog.

Keep work-in-progress skills out

SkillsCapability('./skills', exclude=['draft-invoice-parser'])

Better still, keep drafts in a separate directory and add it only in development:

libraries = ['./skills']
if settings.environment == 'development':
    libraries.append('./skills-draft')

capability = SkillsCapability(libraries)

Different subsets of the same remote source

Filter the registry rather than the capability, so each agent's copy is staged independently:

from pydantic_ai_skills import GitSkillsRegistry

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

office = SkillsCapability(registries=[source.filtered(lambda info: info.name in {'pdf', 'xlsx', 'docx'})])

Writing descriptions the model can act on

The description is the only thing the model sees before loading a skill. It answers one question: should I load this?

# Too vague — the model cannot tell when this applies
description: Helps with documents.

# Better — says what it does and when to reach for it
description: >
  Fill and extract data from PDF forms. Use when the user asks to complete a form,
  read values out of a filled PDF, or merge PDFs.

The Agent Skills limit is 1,024 characters; harness warns above it rather than truncating. Two skills with overlapping descriptions is the usual cause of the model loading the wrong one — make the boundary explicit in both.

Scripts

Argument marshalling

run_skill_script passes args to the script as --key value pairs, so argparse works as written:

args = {'query': 'transformers', 'max_results': 3, 'verbose': True, 'tags': ['ml', 'nlp']}

becomes:

--query transformers --max_results 3 --verbose --tags ml --tags nlp

Booleans become bare flags when true and are omitted when false; lists repeat the flag; None is omitted. Document the arguments in SKILL.md — the model chooses them from your prose, not from a schema.

Script stdout goes back to the model verbatim. Structured output is easier for it to use than a sentence:

# Good
print(json.dumps({'papers': papers, 'total': len(papers)}, indent=2))

# Worse — the model has to parse English to find the numbers
print(f'I found {len(papers)} papers, the first is {papers[0]["title"]}')

Fail loudly

A script that swallows an error and prints nothing leaves the model guessing. Exit non-zero and write to stderr; both reach the model:

try:
    result = fetch(args.url)
except TimeoutError:
    print(f'Timed out fetching {args.url}', file=sys.stderr)
    sys.exit(1)

Bound the runtime

The local executor accepts a timeout, so one hung script does not hang the run:

from pydantic_ai_skills import LocalSkillScriptExecutor, SkillsCapability

capability = SkillsCapability(
    './skills',
    script_executor=LocalSkillScriptExecutor(timeout=30),
)

Error handling

The tools this package registers raise pydantic_ai.ModelRetry for anything the model can fix — an unknown skill, resource, or script name, or a skill it has not loaded yet. The message names what is available, so the model can correct itself within the agent's retry budget:

Resource 'FORMS.md' not found in skill 'pdf-processing'.
Available resources: ['references/FORMS.md', 'references/LAYOUT.md'].
Use the exact name from the skill instructions.

Genuine failures — a script exiting non-zero, an unreadable file — are returned as output rather than raised, so the model can react to them.

Construction-time problems are different: a malformed SKILL.md, a mismatched name, a duplicate, an unknown include entry, or a registry that cannot reach its source all raise immediately. That is deliberate — a misconfigured catalog should fail at startup, not halfway through a user's request.

try:
    capability = SkillsCapability('./skills', registries=[registry])
except ValueError as exc:
    logger.error('skill configuration is invalid: %s', exc)
    raise

Dependency management

Request-scoped values in a file-based script

File-based scripts are subprocesses and never see RunContext. Pass per-request values through the environment:

from pydantic_ai_skills import LocalSkillScriptExecutor, SkillsCapability

executor = LocalSkillScriptExecutor(
    env_vars={'AWS_REGION': 'us-east-1'},                       # static
    context_env_vars_extractor=lambda ctx: {                    # per run
        'REQUEST_ID': ctx.deps.request_id,
        'TENANT_ID': ctx.deps.tenant_id,
    },
)

capability = SkillsCapability('./skills', script_executor=executor, deps_type=AppDeps)

Scope the data by tenant, not just the instructions. A script that reads TENANT_ID and filters on it is a boundary; a SKILL.md asking the model to only look at one tenant is not.

Shared connections in a programmatic skill

When a skill needs a database handle or an API client, define it in Python and reach through ctx.deps:

from pydantic_ai import RunContext
from pydantic_ai_skills import Skill

analytics = Skill(
    name='analytics',
    description='Answer questions about product usage.',
    content='Read `schema`, then run `query` with a read-only SQL string.',
)


@analytics.script
async def query(ctx: RunContext[AppDeps], sql: str) -> str:
    """Run a read-only query."""
    return str(await ctx.deps.database.execute(sql))

Testing

Assert on the catalog, not the model

What you want to know is that the right skills are exposed with the right files. That needs no model:

from pydantic_ai_skills import SkillsCapability


def test_support_agent_exposes_only_support_skills() -> None:
    capability = SkillsCapability('./skills', include=['refunds', 'order-lookup'])

    assert capability.skill_names == ['order-lookup', 'refunds']


def test_pdf_skill_ships_its_script() -> None:
    package = SkillsCapability('./skills').packages['pdf-processing']

    assert 'scripts/fill_form.py' in package.scripts_by_name

Because an unknown include raises, a test that merely constructs the capability already catches a renamed or deleted skill.

Drive the tools without a provider

Use FunctionModel to script the exact tool calls you want to exercise:

from pydantic_ai import Agent
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import FunctionModel


async def test_the_model_can_read_a_skill_resource(tmp_path) -> None:
    steps = [
        ToolCallPart('load_capability', {'id': 'pdf-processing'}),
        ToolCallPart(
            'read_skill_resource',
            {'skill_name': 'pdf-processing', 'resource_name': 'references/FORMS.md'},
        ),
    ]
    calls: list[int] = []

    def model_fn(messages, info):
        calls.append(1)
        step = len(calls) - 1
        if step < len(steps):
            return ModelResponse(parts=[steps[step]])
        return ModelResponse(parts=[TextPart('done')])

    agent = Agent(FunctionModel(model_fn), capabilities=[SkillsCapability('./skills')])
    result = await agent.run('fill in the form')

    assert result.output == 'done'

Note load_capability takes id — it is Pydantic AI's tool, not one this package registers.

TestModel does not work here

TestModel calls every registered tool with synthesized arguments, which means calling load_capability with an id that does not exist and blowing the retry budget. Use FunctionModel for anything involving deferred capabilities.

Stub script execution

from pydantic_ai_skills import CallableSkillScriptExecutor, SkillsCapability

recorded = []


def fake_run(script, args=None, ctx=None):
    recorded.append((script.name, args))
    return '{"papers": []}'


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

This keeps tests fast and hermetic, and lets you assert on the arguments the model chose.

Registries

Cache the clone

The default is a temporary directory that dies with the process, which means re-cloning on every start. Point it somewhere durable:

GitSkillsRegistry(
    'https://github.com/anthropics/skills',
    path='skills',
    target_dir='~/.cache/agent-skills',
)

The second sync() becomes a git pull.

Pin what you shipped

sync() tracks a moving branch. Record what a deployment actually ran with:

registry = GitSkillsRegistry('https://github.com/anthropics/skills', path='skills')
capability = SkillsCapability(registries=[registry])

logger.info('skills at %s: %s', registry.revision(), capability.skill_names)

To pin rather than record, clone a tag with GitCloneOptions(branch='v1.2.0').

Survive an unreachable source at startup

A registry that cannot reach its source raises during construction, which will take your process down with it. Decide deliberately whether that is what you want:

def build_capability() -> SkillsCapability:
    try:
        return SkillsCapability('./skills', registries=[remote])
    except (ValueError, RuntimeError) as exc:
        logger.warning('remote skills unavailable, continuing with local only: %s', exc)
        return SkillsCapability('./skills')

Degrading like this changes what the agent can do, so log it loudly. For a deployment that must not drift, prefer failing fast — an agent silently missing half its skills is harder to diagnose than one that refuses to start.

Air-gapped deployments

Mirror the repository at build time and turn off fetching at run time:

GitSkillsRegistry(
    'https://github.com/anthropics/skills',
    target_dir='/opt/skills-mirror',
    auto_install=False,   # sync() never reaches the network
)

Resolve a name collision

Two registries shipping a pdf skill cannot both be exposed — each name becomes a capability id. CombinedRegistry picks the first and warns. To keep both:

combined = internal.prefixed('acme-') | public.prefixed('anthropic-')

See also