Skip to content

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:

toolset = SpecToolset(specs, get_user=lambda ctx: ctx.deps.principal.user)

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.user is the acting identity — deps.user, or whatever get_user returns. A configured http_request never contributes an identity.
  • request.query_params holds 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 an http_request, so the ambient endpoint's own query string can never reach a serializer or a filter_set.
  • view.action is 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 the else branch of such a class before exposing its spec, and rewrite action in a build_context override if you need a specific one.
  • view.kwargs holds 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:

toolset = SpecToolset(specs, ordering_fields=["created_at", "total_cents"])

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.kwargs provider that reads view.kwargs — the case ordinary params cannot cover, because the provider reads view.kwargs, not params;
  • 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:

UrlKwarg("project_pk", type="integer", 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:

toolset = SpecToolset(specs, host="https://app.example.com")

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:

toolset = SpecToolset(specs, max_retries=3)