Quickstart¶
1. Have some specs¶
SpecToolset works with the ServiceSpec and SelectorSpec objects you already
define for djangorestframework-services. A read selector and a write service:
from rest_framework_services import SelectorKind, SelectorSpec, ServiceSpec
def list_orders(user):
"""List the current user's orders."""
return Order.objects.filter(owner=user)
list_orders_spec = SelectorSpec(
kind=SelectorKind.LIST,
selector=list_orders,
output_serializer=OrderSerializer,
)
def create_order(data, user):
"""Create an order for the current user."""
return Order.objects.create(owner=user, **data)
create_order_spec = ServiceSpec(
service=create_order,
input_serializer=OrderInputSerializer,
output_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
output_serializer=OrderSerializer,
),
)
2. Build the toolset¶
from rest_framework_pydantic_ai import SpecToolset
toolset = SpecToolset(
{
"list_orders": list_orders_spec,
"create_order": create_order_spec,
}
)
Each key is the tool name. The description comes from the selector/service
docstring, the parameter schema from the spec's input serializer, and the
readOnlyHint annotation from the spec kind (selectors read, services mutate).
List selectors additionally accept page and limit tool args, plus ordering
where the selector's filter_set declares one.
3. Run an agent¶
The acting user flows through RunContext.deps. The default
AgentDeps carries it:
from pydantic_ai import Agent
from rest_framework_pydantic_ai import AgentDeps
agent = Agent("anthropic:claude-opus-4-8", deps_type=AgentDeps, toolsets=[toolset])
result = await agent.run(
"show me my last 5 orders, newest first",
deps=AgentDeps(user=request.user),
)
For that request the model can call list_orders with
{"limit": 5, "ordering": "-created"} and the toolset enforces permissions,
runs the selector as request.user, hands ordering to the selector's
filter_set, slices the result, and renders it through
OrderSerializer.
Custom identity¶
If your project carries identity on a richer deps object, hand the toolset a
get_user extractor instead of using AgentDeps:
What a permission class sees¶
Every call is authorized by spec.permission_classes, run against a synthetic
request and view. drf-services supports a permission class reading request,
view.action and view.kwargs off HTTP — anything beyond those three (a
view.queryset, as DjangoModelPermissions wants) is not available. Here is
what this package puts in each:
request.useris the acting identity —deps.user, or whateverget_userreturns. A configuredhttp_requestnever contributes an identity.request.query_paramsholds exactly the query params declared for that tool, and nothing else. A tool that declares none dispatches with an empty query string even when the toolset was given anhttp_request, so the ambient endpoint's own query string can never reach a serializer or afilter_set.view.actionis the tool name — the key the spec is registered under in the mapping you passed. Off HTTP there is no router to name an action, and the tool name is the identity the model called, so it is the honest answer; it is also what the MCP transport reports for the same spec. A permission class branching on viewset action names ("create","retrieve") will not match one of those unless a tool happens to be named that — check theelsebranch of such a class before exposing its spec, and rewriteactionin abuild_contextoverride if you need a specific one.view.kwargsholds the URL kwargs declared for that tool, the off-HTTP counterpart of a route's captures.
The tool catalog is not permission-filtered¶
get_tools advertises every spec to every run. A tool whose permissions will
deny this caller is still listed — the denial happens on the call. That is
deliberate: a permission whose answer depends on the arguments has none to read
at listing time and would hide a tool the caller can actually use, the listing
runs once per model step so a database-backed check would cost a query per spec
per step, and a model that cannot see a tool cannot ask about it. A listing
carries a name, a description and an input schema; no row data.
If a deployment does want a narrower catalog, override is_tool_listed:
from asgiref.sync import sync_to_async
class OpsOnlyToolset(SpecToolset):
async def is_tool_listed(self, name, ctx):
if name != "suspend_account":
return True
# Django refuses ORM access on the event loop, so anything that
# queries has to go through a thread — as dispatch itself does.
return await sync_to_async(ctx.deps.user.groups.filter(name="ops").exists)()
Hiding a tool is a disclosure decision, never an authorization one: the call is
gated by permission_classes whatever this returns.
Unexpected arguments¶
By default the toolset rejects tool args outside a spec's declared input set
— a key the model invented — surfacing them as a ModelRetry so the model
self-corrects. Specs whose declared set is open (a filter_set or **kwargs
selector) are unaffected. Pass unknown_arguments= to change this:
from rest_framework_services import UnknownArguments
# silently drop unexpected keys instead of rejecting them
toolset = SpecToolset(specs, unknown_arguments=UnknownArguments.IGNORE)
Ordering¶
The filter_set owns ordering. Declare a django-filter OrderingFilter
named ordering on the selector's FilterSet and you are done:
import django_filters
class OrderFilterSet(django_filters.FilterSet):
ordering = django_filters.OrderingFilter(
fields=(("created_at", "created"), ("total_cents", "total")),
)
class Meta:
model = Order
fields = ["status"]
drf-services reflects that filter into the tool's input schema as an enum of its
public choices (created, -created, total, -total) — OrderingFilter
subclasses ChoiceFilter, which the schema generator maps to an enum — so the
model is told exactly what it may sort by. At call time the value is handed to
the FilterSet as filter data: it validates the choice, applies its own
param_map, and a value outside the enum comes back as a ModelRetry. The
toolset contributes nothing and takes nothing away.
One vocabulary, one declaration site, and the same ordering your HTTP views already serve.
ordering_fields (deprecated)¶
Deprecated
ordering_fields / tool_ordering_fields emit a DeprecationWarning.
Declare an OrderingFilter on the selector's filter_set instead.
They remain for the one case with no other route — a list selector with no
filter_set:
That advertises an ordering enum of each name and its - prefixed form, and
the toolset applies the chosen value with queryset.order_by. The values are
therefore raw ORM paths, not public names — which is precisely why the two
cannot be mixed: a FilterSet's OrderingFilter speaks public names it maps
itself, several of which resolve to annotation aliases. Declaring
ordering_fields for a tool whose filter_set already advertises ordering
raises at construction rather than letting one vocabulary quietly overwrite the
other.
To migrate, move the names onto an OrderingFilter as (orm_path, public_name)
pairs and drop the ordering_fields argument.
Read-shaping query params¶
page / limit are built in for list selectors and ordering comes from the
filter_set, but you can register your own request-level params with
QueryParam. Each is
advertised as a tool arg, then — instead of reaching the spec as an input — seeded
into request.query_params over the off-HTTP path. That is for whatever reads
request.query_params directly: django-restql field selection, or a custom
serializer that branches on the query string.
You don't need this for filter_set
A SelectorSpec.filter_set's fields are already generated into the tool's
input schema (the [filter] extra) and flow through as ordinary params —
which dispatch_spec hands the FilterSet as its filter_data. So the model
can filter a list selector with no QueryParam declaration at all, and the
same goes for ordering; QueryParam is only for params a
serializer reads off request.query_params.
from rest_framework_pydantic_ai import QueryParam
toolset = SpecToolset(
specs,
# applies to every tool
query_params=[QueryParam("query", description="django-restql field selection")],
# or scope params to one tool
tool_query_params={"list_orders": [QueryParam("status", default="open")]},
)
A registered param is popped before dispatch, so unknown_arguments never flags
it; a declared default is seeded when the model omits the arg. (Names can't be
page / limit / ordering — those are reserved transport keys. ordering is
reserved even when a filter_set owns it: a registered channel pops the value at
call time, so the FilterSet would never see it.)
Requires djangorestframework-services>=0.23, which added the
build_offline_context(query_params=…) seam.
URL-derived values (route captures)¶
Over HTTP a nested route (/projects/{project_pk}/widgets/) supplies
project_pk from the URL, and a selector reads it from view.kwargs — directly,
or through a spec.kwargs provider that scopes by it (a tenant/role lookup). Off
the HTTP path there is no route, so register the value with
UrlKwarg. It is advertised
as a tool arg, then popped and seeded into build_offline_context(kwargs=…),
from where drf-services spreads it into the selector / target pools —
authoritative over the spec params, below a spec.kwargs provider (mirroring
HTTP precedence exactly).
from rest_framework_pydantic_ai import UrlKwarg
toolset = SpecToolset(
specs,
url_kwargs=[UrlKwarg("project_pk", type="integer", description="owning project")],
# or scope to one tool: tool_url_kwargs={"list_widgets": [UrlKwarg("project_pk")]}
)
Reach for UrlKwarg when the value is request state rather than an ordinary
argument to the callable — the axis is where the value has to land, not whether
it is advertised:
- a scoping
spec.kwargsprovider that readsview.kwargs— the case ordinaryparamscannot cover, because the provider readsview.kwargs, notparams; - a closed-surface spec whose route capture must be model-suppliable.
Like QueryParam, a registered kwarg is popped before dispatch (so
unknown_arguments never flags it) and its default is seeded when the model
omits it. A name can't be page / limit / ordering, nor one of drf-services'
pool seeds (request / user / data / instance / serializer /
collection — a caller must not be able to route a value onto those), nor be
registered as both a QueryParam and a UrlKwarg on the same tool.
A capture the spec genuinely cannot run without takes required=True:
The name joins the tool's required list, so the model is told up front. Because
a schema hint is only a hint — models omit required arguments routinely — a call
that omits it raises ModelRetry naming the argument, giving the model a turn to
supply it rather than failing deeper in. required can't be combined with a
default (a default always satisfies the argument, so requiring it would be a
no-op); that raises at construction.
A reflected **extras key is not a route capture¶
A selector typed def list_widgets(user, **extras: Unpack[WidgetExtras]) that
reads extras["project_pk"] already has that key reflected into the tool schema
by drf-services (0.26+) — no UrlKwarg needed for the selector itself, which
receives it through params. Marking it InputRequired makes the model supply
it; that is a schema statement and changes nothing about where the value lands.
The two declarations answer different questions, and only one of them puts a value on the request:
reflected **extras key (± InputRequired) |
registered UrlKwarg |
|
|---|---|---|
| In the tool schema | yes | yes |
| Can be required | yes (InputRequired) |
yes (required=True, plus a ModelRetry when omitted) |
| Reaches the selector | yes, via params |
yes, via the view.kwargs spread |
Reaches view.kwargs |
no | yes |
Ranks above caller-supplied params |
no — it is caller input | yes |
So anything that reads request state rather than its own arguments — a
spec.kwargs provider, extend_queryset, a permission class, an
output_serializer_context provider — sees nothing for a reflected-only key. A
scoping provider doing view.kwargs.get("project_pk") returns None and
mis-scopes every call instead of failing, which is the failure mode worth
naming: it is silent.
Register the UrlKwarg as well when the value is scope. It is a strict superset
— the selector still receives it in **extras, the schema keeps one property and
one required entry (an explicit UrlKwarg wins the merge over a reflected key
of the same name), and the provider gets its value:
# project_pk reflected from WidgetExtras *and* registered here:
# selector's extras -> 7 view.kwargs -> {"project_pk": 7}
toolset = SpecToolset(
specs,
tool_url_kwargs={"list_widgets": [UrlKwarg("project_pk", type="integer", required=True)]},
)
That split mirrors HTTP, where a route capture arrives in the URL and never in
the body — which is what makes it unspoofable. Off the HTTP path, params are
whatever the model chose; a UrlKwarg value outranks them. If a provider scopes
by it, it has to come through the channel that carries that precedence.
UrlKwarg and QueryParam are
drf-services' types,
re-exported here — the declaration is the same whichever transport carries it,
and this package's copy had drifted from the MCP transport's on which names each
reserved. from rest_framework_pydantic_ai import UrlKwarg, QueryParam keeps
working. Requires djangorestframework-services>=0.28.1.
Absolute URLs (file and hyperlinked fields)¶
Off the HTTP path there is no ambient request, so there is no origin to build
absolute URLs from — and DRF's FileField, HyperlinkedIdentityField, and
HyperlinkedRelatedField call request.build_absolute_uri() for every value.
Name your origin and they resolve:
host accepts "example.com", "example.com:8000", or a full origin whose
scheme decides whether links are https. It is toolset-wide, with no per-tool
variant: an origin is a property of the deployment, not of a tool.
Left unset, those fields produce relative URLs (/media/doc.pdf) — usable,
and exactly what they fall back to on their own when no request is in the
serializer context. Nothing is inferred: only your project knows its public
origin, and a guess would emit confidently-wrong links that look valid.
Requires djangorestframework-services>=0.29.1.
Error handling¶
The toolset maps drf-services' failure kinds onto the Pydantic-AI model loop:
| drf-services outcome | What the agent sees |
|---|---|
ServiceValidationError (bad input) |
ModelRetry with the field errors — the model self-corrects |
ServiceError (business rule) |
{"error": "..."} — model-readable content |
| Unresolved instance | {"error": "not found"} |
Unexpected argument (default REJECT) |
ModelRetry naming the unknown key |
Non-integer page / limit, or an ordering outside the declared enum |
ModelRetry — naming the values that are accepted |
An ordering outside a filter_set's OrderingFilter choices |
ModelRetry — the FilterSet rejects it, which arrives as the ValidationError row above |
An ordering name that isn't a real column (a declared ordering_fields entry, or a FilterSet param_map target) |
ModelRetry — an author's error, not the model's; it can't be checked at construction without a queryset |
Denied permission_classes (class-level has_permission or object-level has_object_permission) |
PermissionDenied is raised and aborts the run — see the caveat below |
A tool-failure policy changes the last row
"Aborts the run" is what a plain pydantic_ai.Agent does: nothing catches
the exception, so it propagates out of agent.run. A host that installs a
tool-failure policy catches it and hands the model a failed-tool result
instead, and the run keeps going — free to try the next row in the same
turn. django-pydantic-agent's build_agent installs one by default
(ToolFailureConfig(enabled=True)), so that is the behaviour you get under
it and under django-ag-ui unless you opt out.
The denial itself is unaffected on every host: nothing is dispatched and no
data is rendered. What changes is whether the run survives it, so do not
rely on a PermissionDenied escaping as your only stop signal.
Each ModelRetry row consumes one unit of the tool's retry budget: after
max_retries failed attempts (default 1, pydantic-ai's function-tool
default) the run aborts with UnexpectedModelBehavior. Raise it for models
that need more attempts to converge: