Skip to content

Tools

Registering one

A tool is a plain typed callable registered on a ToolRegistry:

from django_pydantic_agent import ToolCategory, ToolRegistry, tool

registry = ToolRegistry()


@tool(registry, category=ToolCategory.UI_READ)
def find_order(order_id: int) -> dict:
    """Look up an order by id."""
    ...

The name defaults to the function's name, and the description to the first paragraph of its docstring; both can be overridden. Registering a name twice raises ValueError rather than shadowing.

ToolRegistry state lives on the instance — a transport holds one, and tests build a fresh one per scenario. There is no global registry.

Tools must be typed

The registry derives each tool's JSON Schema from the signature at registration time, so every parameter and the return need a concrete annotation. There is no **kwargs: Any escape hatch: an untyped parameter has no schema, and the model would be guessing.

Sync and async callables both work; the registry dispatches either.

Metadata, and where it surfaces

@tool takes four pieces of metadata beyond name and description. All of them end up as JSON-Schema extension keys, because a schema is the one channel that reaches a client without inventing a side channel:

Argument Schema key What it is for
destructive=True x-destructive This tool mutates. AG-UI has no native destructive-tool concept, so it is stamped at the schema root and read client-side to gate execution behind a confirmation — and server-side by the tool guard, for a tool whose schema is the only place its author said so.
category= x-category Coarse grouping (ToolCategory) so a frontend can group or filter, or a system prompt can reason about capability classes.
confirm="Activate this project?" x-confirm The confirmation prompt shown instead of a generic "Run tool?".
summary="Query orders" x-summary A short display label shown on a tool-call card instead of the raw tool name.

category does not gate anything. It is advisory metadata for grouping and policy; destructive is the flag that drives the approval gate.

There is a fifth key that is not a schema stamp: DESTRUCTIVE_METADATA_KEY ("django_pydantic_agent.destructive") rides pydantic-ai's ToolDefinition metadata rather than the schema, because metadata is the channel a bridge controls where the schema belongs to the tool's author. It is read server-side at prepare_tools time by the tool guard, which needs destructiveness for tools whose flag doesn't come from the @tool registry at all — a bridged drf-mcp tool, whose readOnlyHint annotation the bridge maps onto this key.

A registry tool's own destructive=True never travels either channel: it stays on the spec, and the guard reads it from the registry directly. What reaches the guard through the schema is the case where build_input_schema was used on its own and the tool attached through toolsets=.

The tool catalog

Server-side tools execute server-side, so their JSON Schema never reaches the browser — which means a web component can't read an x-summary off it. That is what build_tool_catalog is for: a list of {"name", "summary", "description"?} entries a frontend fetches to label tool-call cards.

summary is always present, resolved through a fallback chain — for registry tools, @tool(summary=…) then a prettified name; for drf-mcp tools, display_name then title then a prettified name.

from django_pydantic_agent import build_tool_catalog

catalog = build_tool_catalog(registry, drf_mcp_server=server, service_specs=specs)

What the catalog does not cover

Only the sources above. A tool reaching the agent through a transport's capabilities= / toolsets= — an arbitrary AbstractToolset attached directly — is not listed, and its card falls back to a prettified tool name.

This is a stated boundary, not a gap waiting to be filled. Pydantic-AI's enumeration is AbstractToolset.get_tools, which is async and takes a RunContext; the catalog is built at configuration time, from a view, with no run in sight. Enumerating "the toolsets we recognise" and skipping the rest would produce a catalog that looks complete and is not — the failure this stack spends its time removing, traded for cosmetics.

Route spec tools through a covered source instead. A SpecToolset or SpecCapability passed to django-ag-ui's service_specs= (0.30+) is attached as itself and enumerated here, so it keeps its labels.

An unlabelled card is a degraded label, not a broken call: the tool executes normally and the frontend shows a prettified name.

Deriving a schema on its own

build_input_schema is the same derivation the registry runs, exposed for projects that want a tool's schema outside the registration flow:

from django_pydantic_agent import build_input_schema

schema = build_input_schema(find_order, destructive=False, category=ToolCategory.UI_READ)

A leading ctx: RunContext[...] parameter is not a tool argument and is left out of the schema: pydantic-ai fills it from the run, so advertising it would ask the model to invent a value for something it cannot supply. Dispatch follows the same rule — registry.call(name, arguments, ctx=ctx) binds it from the caller, and a tool that declares one and is dispatched without a ctx raises rather than being handed None.

See the registry reference for the full signatures.