Hooks Around Skill Loading
You can intercept the moment an agent loads a skill — before and after — using Pydantic AI's hooks. No special support is required from this library.
Why this works
Every skill is a deferred capability, and the model loads one by calling Pydantic AI's built-in
load_capability tool with the skill's name as id. That is an ordinary tool call, so the standard
tool-execution hooks fire around it.
The tools involved:
| Tool | Provided by | Fires when |
|---|---|---|
load_capability |
Pydantic AI | the model loads a skill's instructions |
read_skill_resource |
this package | the model reads a bundled file |
run_skill_script |
this package | the model executes a bundled script |
There is no list_skills tool to hook: the catalog is injected as instructions, not fetched. And
there is no load_skill tool — v1 had one, but the framework's load_capability replaced it. If
you are porting hooks from v1, see Migrating from v1.
Quick start
Register a Hooks capability alongside your skills, filtering each hook to the tool by name. The
hook callbacks take keyword-only call, tool_def, and args; the before hook returns the
(possibly modified) args, and the after hook returns the (possibly modified) result.
from pydantic_ai import Agent
from pydantic_ai.capabilities import Hooks
from pydantic_ai_skills import SkillsCapability
hooks = Hooks()
@hooks.on.before_tool_execute(tools=['load_capability'])
async def before_load(ctx, *, call, tool_def, args):
"""Runs just before a skill's instructions are loaded."""
print(f'About to load: {args["id"]}')
return args
@hooks.on.after_tool_execute(tools=['load_capability'])
async def after_load(ctx, *, call, tool_def, args, result):
"""Runs once the instructions have been loaded."""
print(f'Loaded: {args["id"]}')
return result
agent = Agent(
'anthropic:claude-sonnet-4-6',
capabilities=[SkillsCapability('./skills'), hooks],
)
The argument is id, not skill_name — load_capability is the framework's tool and takes the
capability id, which for a skill is its name.
load_capability is shared
Every deferred capability in the agent loads through the same tool, not just skills. If your agent has other deferred capabilities, check the id before acting:
SKILL_NAMES = set(capability.skill_names)
@hooks.on.before_tool_execute(tools=['load_capability'])
async def before_load(ctx, *, call, tool_def, args):
if args['id'] in SKILL_NAMES:
audit_log.record('skill_loaded', args['id'])
return args
Auditing bundled-file access
The tools this package adds take skill_name as their first argument, so filtering is direct:
@hooks.on.before_tool_execute(tools=['run_skill_script'])
async def before_script(ctx, *, call, tool_def, args):
audit_log.record('script_run', skill=args['skill_name'], script=args['script_name'])
return args
This is the seam for policy enforcement — refusing scripts from particular skills, rate-limiting execution, or recording what ran. It is not a security boundary on its own; see Security.
Blocking a load
Raise from a before hook to stop the tool running. Use ModelRetry when the model can recover:
from pydantic_ai import ModelRetry
ALLOWED = {'pdf-processing', 'data-analysis'}
@hooks.on.before_tool_execute(tools=['load_capability'])
async def restrict(ctx, *, call, tool_def, args):
if args['id'] not in ALLOWED:
raise ModelRetry(f'{args["id"]} is not available in this context.')
return args
For a fixed allowlist, include= on the capability is simpler and cheaper — the skill never reaches
the catalog at all:
SkillsCapability('./skills', include=['pdf-processing', 'data-analysis'])
Reach for a hook when the decision depends on run state — the user's permissions, a quota, the time of day — rather than being known at construction.
Observing without intercepting
If all you want is telemetry, Pydantic AI's
instrumentation already records every tool call with its
arguments, including load_capability. Reach for hooks when you need to change behaviour.