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 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 django_service_specs 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 django_spec_agents import AgentDeps
from django_spec_agents.spec_tools import 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.
The registry is django-service-specs', and it accepts any spec that compiles to
one of the kernel's, and so does the toolset: a django-service-specs
ServiceSpec or SelectorSpec, or any value the kernel's kernel_spec
compiles to one. The toolset reads every spec through kernel_spec, so it asks
the compiled spec what to advertise and how to call it, whichever library
declared it. A value that compiles to no spec raises TypeError naming the
tool when the toolset is built, in the kernel's words.
A spec declared on the kernel¶
A django-service-specs spec needs no DRF. Its permissions are the kernel's
PermissionChecks, and its arguments are its declared reads: the kernel
reflects nothing off a native callable's signature, so each argument the model
may send is a Parameter, and the callable takes it by name beside the pool
seeds (user here). A native spec declaring no reads advertises no argument of
its own, and a call sending one is handed back as name: Unknown argument.
from django_service_specs import (
Parameter,
Parameters,
PermissionCheck,
SelectorKind,
SelectorSpec,
ServiceSpec,
)
from django_spec_agents.spec_tools import SpecToolset
from orders.models import Order
class SignedIn(PermissionCheck):
def has_permission(self, principal, spec) -> bool:
return bool(principal is not None and principal.is_authenticated)
def orders_in_status(*, user, status: str) -> list[dict]:
"""The caller's orders in one status."""
return list(Order.objects.filter(owner=user, status=status).values("id", "status"))
def set_order_note(*, user, pk: int, note: str) -> dict:
"""Write the note on one of the caller's orders."""
Order.objects.filter(owner=user, pk=pk).update(note=note)
return {"id": pk, "note": note}
list_orders = SelectorSpec(
kind=SelectorKind.LIST,
selector=orders_in_status,
reads=Parameters.of(Parameter("status", "string", default="open")),
permissions=[SignedIn()],
)
note_order = ServiceSpec(
service=set_order_note,
reads=Parameters.of(
Parameter("pk", "integer", required=True),
Parameter("note", "string", required=True),
),
permissions=[SignedIn()],
)
toolset = SpecToolset({"list_orders": list_orders, "note_order": note_order})
list_orders advertises status, defaulting to open, beside the page and
limit every list tool takes, and returns a page of the dicts its selector
returns. note_order requires pk and note. A registry takes these specs as
it takes any other, and the toolset serves them from either.
A registry or its mapping¶
SpecToolset(registry) and SpecToolset(registry.specs()) build the same
tools: the mapping is the flattened name -> spec view, and everything the
toolset reads is on the spec. Pass the registry when you have one, so a
filtered view of it can feed each toolset.
The same goes for the capability:
from django_spec_agents.spec_tools 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 spec declares for an agent¶
A spec declares what a caller with no HTTP request has to be told, because
the URLconf and query string tell an HTTP one for free: reads= names the route
captures and query values it reads, and an OutputField's marking= says how
the field is presented to an agent. Both are the spec's: each read is one of its
parameters, and the markings are part of its output.
from dataclasses import replace
from django_service_specs import Parameter, Parameters
registry.register(
"list_orders",
replace(
specs.list_orders,
reads=Parameters.of(Parameter("tenant_pk", "integer", required=True)),
shapes=Parameters.of(Parameter("fields", "string")),
),
tags=("read", "public"),
)
replace is for a spec declared elsewhere; a spec you write declares reads=
and shapes= in its own constructor. SpecRegistry.register takes no contract of its own:
the spec is the one place it is said.
SpecToolset reads them off the compiled spec, and an MCP server's
register_specs reads the same spec. That is the point of putting them there:
an operation needs the identical description of the absent request whichever
agent transport calls it, which is why a toolset takes no channel declarations
of its own:
SpecToolset has no query_params=, tool_query_params=, url_kwargs= or
tool_url_kwargs=, and passing one is a TypeError.
A field's marking is part of the spec's output declaration, so every projection
of the spec carries it, whichever transport or mapping it is reached through.
A registry and registry.specs() build the same tools. The tool's schema
is the compiled spec's, the spec's reads are advertised, a read's default
reaches a call that leaves it out or sends null, and a value the model sends
under one of those names is admitted and handed to the spec as the kernel hands
any caller's. A read declared required=True and sent as null is handed back
as name: This field is required., the kernel's check. A read declared required=True with a default is not refused: the
default answers a call that leaves it out, as the kernel reads it. A native
read declared nullable keeps a null sent under it as the value.
Every read the spec declares is checked against the
pool seeds when the toolset is built,
whichever source it came from: a read named after a registered seed, at the top
level or on a service's target lookup, is refused there, naming the tool. A
parameter in shapes=, which no callable takes, may not be named page,
limit or ordering, which a list tool takes for itself. A read named
ordering that a callable takes is an argument like any other, advertised and
left in the call.
Arguments the server supplies¶
Some arguments are not the model's to choose. An agent run scoped to one
project should not be able to list another project's rows by sending a
different project_pk, and a model asked for an identifier it cannot know
tends to invent one or send the parameter's name. tool_server_arguments
answers for such names per tool, with django-service-specs'
ServerArguments:
from django_service_specs import ServerArguments
toolset = SpecToolset(
registry,
tool_server_arguments={
"list_document_vendors": ServerArguments(
values={"project_pk": str(document.project_id)},
withheld=frozenset({"draw_request_pk"}),
),
},
)
- A bound name (
values) carries the server's value on every call. It satisfies arequiredparameter, and every reader of the call sees the one value: akwargs=provider taking its name is handed it, and the selector receives it as its parameter. - A withheld name is absent from every call, so the read's declared default, or the callable's own, answers. Withholding the other names of an either-or scope is how a tool taking a project or a draw request is held to the project.
- Neither is advertised. Both leave the tool's
propertiesandrequired, and the instructions name neither. If the model sends one anyway, its value is dropped before the call is checked, never refused, so a guess costs no retry, and the toolset logs the tool and the names atWARNING, never the values.
Each name must be one the tool's spec declares: a selector or target-lookup
parameter, or one of its reads. The toolset checks every binding when it is
built, with django-service-specs' check_server_arguments and the toolset's
own pool_seeds, and raises ImproperlyConfigured naming the tool for a name
the spec does not declare, a name a pool seed or a typed kwargs= provider
fills, a withheld name no call could do without, or a bound value its
parameter refuses. A key naming a tool the toolset does not expose raises
ValueError, as every per-tool mapping does.
The bindings are fixed when the toolset is built, like its tool definitions. For a per-run scope, build a toolset per run, from the run's scope. A tool counts as scoped by the run only when the run binds one of the scope names it reads: its other optional scope reads are then withheld too, so the model cannot widen the scope through a sibling, while a required one the run does not bind stays the model's to send, since withheld no call could supply it. A tool reading only scope names the run does not bind gets no binding and keeps them all:
SCOPE_NAMES = frozenset({"project_pk", "draw_request_pk", "document_pk"})
def scope_bindings(registry, scope):
"""Each tool the run's scope reaches: the bound names, and optional siblings withheld."""
bindings = {}
for entry in registry.all():
reads = [read for read in entry.spec.reads if read.name in SCOPE_NAMES]
values = {read.name: scope[read.name] for read in reads if read.name in scope}
if not values:
continue
bindings[entry.name] = ServerArguments(
values=values,
# A required read stays the model's: withheld, no call could supply it.
withheld=frozenset(
read.name for read in reads if read.name not in values and not read.required
),
)
return bindings
toolset = SpecToolset(registry, tool_server_arguments=scope_bindings(registry, scope))
SpecCapability takes the same keyword.
What stays per toolset¶
The rest of SpecToolset's signature, because it is genuinely this mount's:
get_user, unknown_arguments, max_retries, the result-size and page
ceilings. A publicly exposed MCP endpoint and an in-process toolset carry
different risk, so one shared number would be a regression rather than a
simplification.
Moving a channel declaration off the toolset¶
A toolset that declared a read at the mount declares it on the spec instead, as
reads= and shapes= (see
what the spec declares for an agent).
A read's default is only a default: the model can override it, and it stays
advertised. To keep a value the model should not choose, use
tool_server_arguments.
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.