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.