pydantic-ai-apcore
pydantic-ai integration for apcore.
Works in both directions:
ApcoreToolset— expose governed apcore modules to a pydantic-ai agentregister_toolset()— register the tools an agent already has as apcore modules, so they gain schema validation, ACL, approval gates, and audit, and can then be served over MCP, CLI, or A2A without being rewritten
Features
- AbstractToolset implementation — drop-in toolset for pydantic-ai agents
- Schema passthrough — apcore
input_schemamapped directly toToolDefinition.parameters_json_schema - Annotation mapping —
destructive,requires_approval,readonly,idempotent,open_world,streamingannotations surfaced as tool metadata and description warnings - Include/exclude filtering — glob-style patterns to select which apcore modules to expose
- Approval gating — modules with
requires_approval=Trueemitkind="unapproved"for pydantic-ai's human-in-the-loop flow - Per-call caller identity — resolve the calling principal from the agent run context so apcore ACL rules can tell agents apart
- Provider-safe tool names — dotted module IDs are translated to names the major model providers accept
- Reverse registration — existing pydantic-ai tools become governed apcore modules, ready for MCP/CLI/A2A
Requirements
- Python >= 3.10
- apcore >= 0.26.0
- apcore-toolkit >= 0.10.0
- pydantic-ai-slim >= 1.32.0
Both lower bounds are the earliest versions the test suite actually passes on, not the earliest version that happens to expose the APIs used here:
| Verified | Fails on | Why | |
|---|---|---|---|
| apcore | 0.26.0 – 0.27.0 | 0.12.0 | the code itself works from 0.13.0, where cacheable / paginated arrive; the effective floor is raised to 0.26.0 by apcore-toolkit |
| apcore-toolkit | 0.6.0 – 0.10.1 | — | floor kept at 0.10.0 to match the other *-apcore integrations, so installing several together cannot conflict |
| pydantic-ai-slim | 1.32.0 – 2.34.0 | 1.31.0 | earlier releases pin an OpenTelemetry API that no longer resolves |
ToolDefinition.sequential needs 1.0.10 and kind="unapproved" needs 1.0.0, so
the pydantic-ai floor is set by installability rather than by API surface.
Both major lines are verified: the test suite passes on 1.32.0, 1.66.0,
2.0.0, and 2.34.0.
Installation
pip install pydantic-ai-apcore
# Optionally project registered modules onto other surfaces
pip install "pydantic-ai-apcore[mcp]" # serve them as an MCP server
pip install "pydantic-ai-apcore[cli]" # expose them on the command line
pip install "pydantic-ai-apcore[all]"
Quick Start
from apcore import Registry, Executor, module
from pydantic_ai import Agent
from pydantic_ai_apcore import ApcoreToolset
# 1. Set up apcore registry and modules
registry = Registry()
executor = Executor(registry=registry)
@module(id="math.add", registry=registry)
def add(a: int, b: int) -> dict:
"""Add two numbers."""
return {"result": a + b}
# 2. Create agent with ApcoreToolset
agent = Agent(
"openai:gpt-4o",
toolsets=[ApcoreToolset(registry, executor)],
)
# 3. Run
result = agent.run_sync("What is 3 + 5?")
print(result.output)
API
ApcoreToolset
ApcoreToolset(
registry: Registry,
executor: Executor,
*,
identity: Identity | None = None,
identity_resolver: Callable[[RunContext[AgentDepsT]], Identity | None] | None = None,
include: list[str] | None = None,
exclude: list[str] | None = None,
toolset_id: str | None = None,
max_retries: int = 1,
sanitize_tool_names: bool = True,
)
| Parameter | Description |
|---|---|
registry |
apcore Registry containing discovered modules |
executor |
apcore Executor for running modules |
identity |
Fixed Identity used for every call — suitable when the whole agent runs as one principal |
identity_resolver |
Derives the Identity per call from the run context; takes precedence over identity |
include |
Glob-style patterns to include (e.g. ["api.*"]) |
exclude |
Glob-style patterns to exclude (e.g. ["internal.*"]) |
toolset_id |
Optional unique ID for this toolset instance |
max_retries |
Retries pydantic-ai allows per tool call |
sanitize_tool_names |
Translate dotted module IDs into provider-safe tool names |
Caller identity and ACL
apcore matches ACL callers patterns against Context.call_chain[-1], and
conditions.roles against the Context identity. This toolset writes the
resolved identity to both, so a rule like the following matches as written:
ACLRule(
callers=["agent.*"],
targets=["crm.delete"],
effect="allow",
conditions={"roles": ["data_admin"]},
)
Use identity_resolver when one toolset serves several principals — typically
reading whatever the surrounding application already knows from ctx.deps:
@dataclass
class AgentIdentity:
caller_id: str
roles: tuple[str, ...]
toolset = ApcoreToolset(
registry, executor,
identity_resolver=lambda ctx: Identity(
id=ctx.deps.caller_id, type="ai", roles=ctx.deps.roles
),
)
With neither identity nor identity_resolver, calls reach apcore without a
caller, and a default-deny ACL will reject them.
Tool naming
apcore module IDs are dotted by convention (executor.crm.read), but the major
model providers accept only [a-zA-Z0-9_-] in a tool name. Names are therefore
translated (executor_crm_read) before being offered to a model, and the
original module ID travels in ToolDefinition.metadata["apcore_module_id"] so
execution still targets the right module. Two IDs that translate to the same
name get a numeric suffix. Pass sanitize_tool_names=False to offer IDs
verbatim.
Filtering
# Only expose api.* modules, but not api.internal
toolset = ApcoreToolset(
registry, executor,
include=["api.*"],
exclude=["api.internal"],
)
Exclude takes precedence over include.
Annotation Handling
apcore ModuleAnnotations are mapped to pydantic-ai as follows:
| Annotation | Effect |
|---|---|
destructive=True |
Adds WARNING: DESTRUCTIVE to the description, and sets sequential=True so the call does not overlap with other tool calls |
requires_approval=True |
Sets kind="unapproved" + adds REQUIRES APPROVAL to description |
readonly, idempotent, open_world, streaming, cacheable, paginated |
Surfaced in ToolDefinition.metadata |
discoverable=False |
Excluded — Registry.list() omits these modules |
Other descriptor fields also reach the model: documentation and up to three
examples are appended to the description, and a sunset_date becomes a
DEPRECATED notice. version and tags travel in metadata.
Registering existing tools
The reverse direction: take the tools an agent already has and put them behind the apcore execution boundary.
from apcore import Registry
from pydantic_ai.toolsets.function import FunctionToolset
from pydantic_ai_apcore import register_toolset
toolset = FunctionToolset()
def send_invoice(customer_id: str, amount: float) -> dict:
"""Send an invoice to a customer.
Args:
customer_id: Who to bill.
amount: How much, in USD.
"""
return {"customer_id": customer_id, "sent": True}
toolset.add_function(send_invoice, takes_ctx=False)
registry = Registry()
result = register_toolset(toolset, registry, prefix="billing.")
# result.registered -> ["billing.send_invoice"]
add_function(takes_ctx=...)behaves identically on pydantic-ai 1.x and 2.x. The.tooldecorator does not: 2.0 changed it to require aRunContextfirst parameter and added.tool_plainfor functions that take none. Either decorator works if you only target one major version;add_functionworks on both.
Schemas are derived by apcore from each function's signature and docstring, the
same way a hand-written module would be, so the results are not second-class. A
tool's requires_approval flag carries over to the apcore annotation of the
same name — approval semantics survive the trip in both directions.
Tools that take a RunContext are skipped, and reported in
result.skipped with the reason. apcore invokes a module without a pydantic-ai
run context, and passing None would hand the function a broken context rather
than fail honestly.
Metadata gaps get flagged
pydantic-ai does not require parameter descriptions
(require_parameter_descriptions defaults to False), so a tool can reach a
model with nothing but parameter names. Scanning produces
apcore_toolkit.ScannedModule values whose warnings say where that happened:
for m in result.modules:
for w in m.warnings:
print(m.module_id, w)
# undocumented has no description for: x, y. A model sees only the parameter names.
Pass enhancers= to fill the gaps before registration — for instance
apcore_toolkit.AIEnhancer, which uses a local SLM and is off unless
APCORE_AI_ENABLED is set:
from apcore_toolkit import AIEnhancer
register_toolset(toolset, registry, enhancers=[AIEnhancer()])
Closures register on equal footing
Registration deliberately does not go through apcore_toolkit.RegistryWriter.
That writer resolves a callable from ScannedModule.target by import path,
which cannot reach a tool defined inside a function — a common shape in
pydantic-ai, where tools close over dependencies. The callables are held from
the scan instead, so such tools register and execute normally; their
target is still recorded, with a warning that it is not importable.
Once registered, they are ordinary apcore modules — anything that serves a registry can serve them:
from pydantic_ai_apcore import serve_mcp # needs the [mcp] extra
serve_mcp(registry)
serve_mcp / serve_cli are thin forwards to apcore-mcp and apcore-cli,
kept behind extras so the base install stays minimal. Calling one without its
extra installed raises an ImportError naming the command to fix it.
Two approval checkpoints
requires_approval produces a tool the agent run suspends on, returning
DeferredToolRequests. That is pydantic-ai asking whether the model may make
the call at all. The apcore approval gate is separate and runs later, once the
sanctioned call reaches the execution pipeline — the two are in series, not
duplicated. See examples/governance_comparison.py,
which demonstrates the ordering and runs offline without a model provider.
Examples
Each demo is a self-contained directory with its own README.md and acl.yaml,
following the same layout as the other *-apcore integrations. All of them run
offline — FunctionModel drives the tool calls, so no model provider or API key
is involved.
| Demo | Shows |
|---|---|
acl_demo |
ACL rules loaded from YAML via APCORE_ACL_PATH, and how identity_resolver gets the caller into the fields those rules match against. |
approval_demo |
The two approval checkpoints — pydantic-ai's and apcore's — and the order they fire in. |
scanner_demo |
The reverse direction: existing agent tools registered as governed apcore modules, then denied by ACL and gated on approval. |
python examples/acl_demo/app.py
python examples/approval_demo/app.py
python examples/scanner_demo/app.py
Each demo's ACL lives in its own acl.yaml, so the policy can be changed and
re-run without touching code:
default_effect: deny
rules:
- description: Only data-admin agents may delete
callers: ["agent.*"] # matched against Context.call_chain[-1]
targets: ["crm.delete"] # module-id glob patterns
effect: allow
conditions:
roles: ["data_admin"]
Development
git clone https://github.com/aiperceivable/pydantic-ai-apcore.git
cd pydantic-ai-apcore
pip install -e ".[dev]"
pytest
License
Apache-2.0
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pydantic_ai_apcore-0.1.0.tar.gz.
File metadata
- Download URL: pydantic_ai_apcore-0.1.0.tar.gz
- Upload date:
- Size: 28.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a0a4a1c7d8134978e7036508cb5cf24863fd7c0f5a3b011722c91c9b27de65b
|
|
| MD5 |
6f4d119b8e9e96aab1fc33ef0bd99155
|
|
| BLAKE2b-256 |
f559699ef0df6493c46b8a570cd37d77eff698468cf1d5b4d72269a59ab16afa
|
File details
Details for the file pydantic_ai_apcore-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pydantic_ai_apcore-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2cddfcfc21a0a71630bf14d2b2c0b114ca6bacb038c315954533c43bfb86e694
|
|
| MD5 |
bfdfe06630611852cd9e16d02d591e0f
|
|
| BLAKE2b-256 |
3d6efc14294211e0044e8013824197f4660355e17d8153ba1a46588047fcf647
|