Skip to content

Declaring specs once, across transports

If the agent is the only place your project exposes its specs, keep passing a plain dict — this page buys you nothing.

It pays off when the same specs are also exposed elsewhere: over MCP with djangorestframework-mcp-server, or as ordinary HTTP views. Each transport has to be told which specs it exposes, so the list gets written once per transport and the copies drift — a spec is added to the MCP wiring and forgotten in the agent's, or the same operation is named create_order in one place and something else in the other.

SpecRegistry (djangorestframework-services 0.27+) is the one declaration site. SpecToolset and SpecCapability accept one anywhere they accept a mapping — there is no separate constructor to learn.

Declare once

# orders/registry.py
from rest_framework_services import SpecRegistry

registry = SpecRegistry()


# orders/apps.py
from django.apps import AppConfig


class OrdersConfig(AppConfig):
    name = "orders"

    def ready(self) -> None:
        from orders import specs
        from orders.registry import registry

        registry.register("list_orders", specs.list_orders, tags=("read", "public"))
        registry.register("refund_order", specs.refund_order, tags=("write", "admin"))

Hand it to the agent

from pydantic_ai import Agent

from rest_framework_pydantic_ai import AgentDeps, SpecToolset
from orders.registry import registry

agent = Agent(model, deps_type=AgentDeps, toolsets=[SpecToolset(registry)])

That is the whole change. Each entry becomes one tool, named as it is in the registry, in registration order. Everything else behaves exactly as when you pass a dict — because registry.specs() is that dict.

The same goes for the capability:

from rest_framework_pydantic_ai import SpecCapability

AgentConfig(capabilities=[SpecCapability(registry)])

Project several toolsets from one declaration

by_tag and subset each return a new registry holding a snapshot, so one declaration site can feed several toolsets with no shared state:

reads = SpecToolset(registry.by_tag("read"), id="reads")
admin = SpecToolset(registry.by_tag("admin"), id="admin")

agent = Agent(model, deps_type=AgentDeps, toolsets=[reads, admin])

With capabilities, give each its own id — the id keys defer_loading's catalog entry, so two capabilities sharing one would collide:

AgentConfig(
    capabilities=[
        SpecCapability(
            registry.by_tag("read"),
            id="reads",
            description="Read-only order and customer lookups.",
        ),
        SpecCapability(
            registry.by_tag("admin"),
            id="admin",
            description="Account administration: suspend, refund, reassign.",
            defer_loading=True,
        ),
    ]
)

Here defer_loading hides the admin tools behind Pydantic-AI's native load_capability tool until the model asks for them — worth doing when the admin surface is large and rarely needed.

Give every deferred capability a description. It is the line the model picks from: the catalog renders - {id}: {description} when there is one and a bare - {id} when there is not, so a few undescribed capabilities leave the model choosing between names alone — and it will either guess or load all of them, which is the cost deferring was meant to avoid. description names the capability; the separate descriptions mapping relabels individual tools.

What the registry does not carry

Only the invariant part of an operation: which spec, its canonical name, its tags. Everything on SpecToolset's signature stays per-toolset, because it is transport-specific and meaningless to the other transports — get_user, unknown_arguments, max_retries, and the QueryParam / UrlKwarg registrations:

SpecToolset(
    registry.by_tag("read"),
    query_params=[QueryParam(name="fields")],
    tool_url_kwargs={"list_orders": [UrlKwarg(name="tenant_pk")]},
)

Per-tool maps like tool_query_params / tool_url_kwargs key off the registry's names, and an unknown key still raises — a typo is a configuration error, not a silent no-op.

Names

Registry names are free-form, but tool names are not: model providers constrain them to [a-zA-Z0-9_-]{1,64}. A registry name outside that shape raises when the toolset is built, rather than failing later at the provider boundary. If you share a registry with a transport that has looser naming, keep the names tool-safe.