Skill Registries
A registry is a source of skill libraries. It fetches Agent Skill packages from wherever they
live and materializes them as a local directory, which SkillsCapability hands to harness.
That is the whole contract — one synchronous method:
class SkillRegistry(ABC):
@abstractmethod
def sync(self) -> Path:
"""Materialize the skills and return the local library directory."""
sync() is idempotent: calling it again refreshes the local copy (a git pull, an S3 re-sync)
rather than starting over. Registries do not parse SKILL.md — validating and rendering the
packages in the directory they return is harness's job.
Git
pip install "pydantic-ai-skills[git]"
from pydantic_ai_skills import GitSkillsRegistry, SkillsCapability
registry = GitSkillsRegistry(
'https://github.com/anthropics/skills',
path='skills', # sub-path inside the repo holding the packages
target_dir='~/.cache/agent-skills', # where to clone; defaults to a temp dir
)
capability = SkillsCapability(registries=[registry])
It reads the filesystem after cloning and never calls a hosting platform's API, so it works with GitHub, GitLab, Bitbucket, and self-hosted servers over HTTPS or SSH.
Authentication
# Personal access token (falls back to $GITHUB_TOKEN when omitted)
GitSkillsRegistry('https://github.com/acme/private-skills', token='ghp_...')
# SSH key
GitSkillsRegistry('git@github.com:acme/private-skills.git', ssh_key_file='~/.ssh/id_ed25519')
A token is embedded in the clone URL, never in repr() or in an error message. The SSH path sets
GIT_SSH_COMMAND with StrictHostKeyChecking=accept-new, and warns if the key file is readable by
group or other.
Shallow and sparse clones
A large monorepo is worth narrowing:
from pydantic_ai_skills import GitCloneOptions, GitSkillsRegistry
GitSkillsRegistry(
'https://github.com/anthropics/skills',
path='skills/pdf',
clone_options=GitCloneOptions(
depth=1,
single_branch=True,
sparse_paths=['skills/pdf'],
multi_options=['--filter=blob:none'],
),
)
Offline and air-gapped
GitSkillsRegistry(
'https://github.com/anthropics/skills',
target_dir='/opt/skills-mirror',
auto_install=False, # sync() never reaches the network
)
With auto_install=False, sync() returns whatever is already on disk and raises a clear error if
nothing has been cloned.
Pinning a version
sync() tracks a moving branch. To record which commit a run actually used:
registry.sync()
print(registry.revision()) # 'a1b2c3d...' or None if not cloned
To pin rather than record, use GitCloneOptions(branch='v1.2.0') with a tag.
S3
pip install "pydantic-ai-skills[s3]"
from pydantic_ai_skills import S3SkillsRegistry
registry = S3SkillsRegistry(
bucket='acme-agent-skills',
prefix='skills',
target_dir='~/.cache/agent-skills',
)
Each sync() mirrors the prefix: the cached subtree is cleared first, so a skill deleted from the
bucket stops appearing locally.
Connection details all live on the boto3 client, which makes any S3-compatible store work:
import boto3
from botocore.config import Config
client = boto3.client(
's3',
endpoint_url='http://localhost:9000',
aws_access_key_id='minioadmin',
aws_secret_access_key='minioadmin',
config=Config(s3={'addressing_style': 'path'}),
)
S3SkillsRegistry(bucket='skills', boto3_client=client)
With no client, a default boto3.client('s3') is built, using boto3's standard credential chain.
auto_install=False works as it does for Git. revision('pdf') reports the newest object
modification time for one skill.
Object keys are checked before download: a key that would write outside target_dir raises rather
than escaping.
Local
from pydantic_ai_skills.registries import LocalSkillsRegistry
LocalSkillsRegistry('./skills')
Passing a local directory straight to SkillsCapability(directories=...) is simpler and does the
same thing. Reach for this only when a local library needs composing — merged with a remote one,
prefixed, or filtered — since composition operates on registries.
Composition
Every registry has filtered(), prefixed() and renamed(), and | merges two. Each returns a
view; the wrapped registry is never modified.
Composition works by staging: the wrapper syncs what it wraps, then copies the packages it wants into a new directory under the names it wants. That is what makes the result a real library harness can read.
Filtering
The predicate receives a SkillInfo — the skill's name, description, and directory:
pdf_only = registry.filtered(lambda info: 'pdf' in info.name)
documents = registry.filtered(lambda info: 'document' in info.description.lower())
Prefixing and renaming
vendor = registry.prefixed('vendor-') # 'pdf' → 'vendor-pdf'
aliased = registry.renamed({'documents': 'pdf'}) # {new_name: original_name}
Both rewrite each staged package's frontmatter name as well as its directory name, because harness
requires the two to agree. A name that would not be valid — an uppercase prefix, say — raises at
sync() naming the operation that produced it, rather than surfacing later as an opaque harness
error.
Merging
from pydantic_ai_skills.registries import CombinedRegistry
combined = CombinedRegistry(registries=[internal, public])
combined = internal | public # the same thing
Earlier registries win on a duplicate skill name, and the shadowed one is reported with a
UserWarning. To expose both, prefix them:
combined = internal.prefixed('acme-') | public.prefixed('anthropic-')
Chaining
Wrappers compose, each staging from the previous one's output:
source = (
GitSkillsRegistry('https://github.com/anthropics/skills', path='skills')
.filtered(lambda info: info.name in {'pdf', 'xlsx', 'docx'})
.prefixed('office-')
)
Note that order matters: prefixed().filtered() gives the predicate the prefixed names, because
filtering runs against the staged library.
Where staged libraries live
By default each wrapper stages into a temporary directory that lives as long as the process. Pass
target_dir= to pin it:
from pydantic_ai_skills.registries import FilteredRegistry
FilteredRegistry(
wrapped=source,
predicate=lambda info: 'pdf' in info.name,
target_dir='./staged-skills',
)
An existing target_dir is emptied on each sync, so a narrowed filter does not leave the previous
run's skills behind.
Inspecting a registry
registry.skill_names() # ['pdf', 'xlsx']
registry.skill_infos() # [SkillInfo(name='pdf', description='...', directory=...), ...]
Both sync first. Useful for a CLI or a health check, without building an agent.
Writing your own
One method:
from pathlib import Path
from pydantic_ai_skills import SkillRegistry
class HttpArchiveRegistry(SkillRegistry):
"""Download and unpack a tarball of skill packages."""
def __init__(self, url: str, target_dir: Path) -> None:
self.url = url
self.target_dir = target_dir
def sync(self) -> Path:
download_and_extract(self.url, self.target_dir)
return self.target_dir
You get filtered(), prefixed(), renamed(), |, skill_names() and skill_infos() for free.
The directory you return must be a library: its immediate children are skill packages, and it
must not itself contain a SKILL.md. harness rejects the latter with a clear error.
Lifecycle
A registry that was not given a target_dir owns a temporary directory tied to its own lifetime.
Keep the registry referenced for as long as you need the files:
# Fine: the capability holds the registry.
capability = SkillsCapability(registries=[GitSkillsRegistry(url)])
# Not fine: the registry is collected, taking its clone with it.
library = GitSkillsRegistry(url).sync()
Pass target_dir= for a cache you control. Those directories are never cleaned up automatically.
Trust
Registry skills are the least-trusted source there is: their instructions steer the model and their scripts run wherever your executor puts them. Use a sandbox executor for anything you do not control, and read Security before pointing an agent at a repository you do not own.