Key concepts¶
This page explains the moving parts behind the Quickstart. For exact signatures, see the API reference.
The tool registry and @tool¶
A ToolRegistry is an ordered, named collection of
server-side tools. State lives on the instance — a
DjangoAGUIView holds one, and tests build a
fresh registry per scenario. There is no module-level global registry.
Each tool is a ToolSpec: a frozen dataclass bundling
the callable with its name, description, a destructive flag, a
ToolCategory, and the optional confirm (a
human-readable confirmation prompt) and summary (a tool-call card label)
strings. The @tool decorator (and the registry's add)
builds the spec and registers it, defaulting the name to the function name and
the description to the first paragraph of its docstring.
At registration the registry derives a JSON Schema from the function signature
and stores it alongside the spec as a ToolBinding,
so tool listings never re-introspect on each request. Tool callables must be
fully typed — an untyped tool breaks schema generation.
The registry can dispatch synchronously (call) or asynchronously (acall);
call refuses coroutine functions rather than silently returning an un-awaited
coroutine.
Destructive metadata and x-destructive¶
AG-UI has no native concept of a "risky" tool, so
build_input_schema stamps two JSON-Schema
extensions at the schema root:
x-destructive: true(the key isX_DESTRUCTIVE_KEY) whendestructive=True.x-category(the key isX_CATEGORY_KEY) carrying the tool'sToolCategoryvalue.x-confirm(the key isX_CONFIRM_KEY) carrying theconfirm=confirmation prompt, when set.x-summary(the key isX_SUMMARY_KEY) carrying thesummary=tool-call card label, when set.
AG-UI passes these extensions through verbatim. A client (such as the
@artooi/ag-ui-web-component) reads x-destructive and gates execution behind
an inline confirmation card (showing x-confirm as the prompt and x-summary
as the card label). The wire stays vanilla AG-UI — the gating is purely
client-side. The server's canonical statement of the policy is
needs_confirmation: a tool needs
confirmation when it is destructive and the project has not set
AUTO_CONFIRM.
DEFAULT_SYSTEM_PROMPT steers the model to
call destructive tools directly (with the right arguments) and let the client
gate them, rather than refusing or asking for confirmation in-band.
build_input_schema handles the primitive parameter types — str, int,
float, bool, list[T], dict[str, Any], and X | None unions; richer
types fall back to an empty (but wire-valid) schema fragment.
Building the agent: AgentConfig and build_agent¶
build_agent turns a registry plus an
AgentConfig into a Pydantic-AI Agent.
AgentConfig is a frozen record bundling the resolved model, instructions,
audit_logger, model_settings, retries, and the already-resolved toolsets
/ capabilities — so the call site passes one record instead of a long keyword
list.
Each registry tool is registered as a plain Pydantic-AI tool. When an
audit_logger is set, build_agent wraps every tool call to time it and record
success or failure, preserving the original signature so Pydantic-AI's schema
generation is unaffected. Frontend tools declared in the request are merged by
the adapter and are not registered here.
For total control over construction, set
AGENT_FACTORY to a callable matching
AgentFactoryFn; it replaces build_agent
entirely.
The audit boundary: the AuditLogger protocol¶
AuditLogger is a runtime-checkable Protocol with a
single method, record(event: AuditEvent). Each
AuditEvent is a frozen record of one tool
invocation: tool name, a string-ified arguments repr, duration in milliseconds,
a success flag, and either an error string or a result size.
Two implementations ship:
NullAuditLogger— discards every event. The default.LoggingAuditLogger— writes to the Pythonloggingframework (INFOon success,WARNINGon failure).
Projects supply their own (Sentry, Honeycomb, custom) by pointing
AUDIT_LOGGER at a dotted path, resolved by
resolve_audit_logger.
Streaming: DjangoAGUIView and the AGUIAdapter¶
DjangoAGUIView is an async, callable view
instance. On each POST it:
-
Establishes the user. Authentication is the host's responsibility, but the view offers two hooks: a
get_user(request)callable whose return value is assigned ontorequest.user(so tools, the drf-mcp bridge, and conversation ownership act as that user), andrequire_authenticated=True, which fails closed — anonymous requests get401with JSON{"error": "authentication required"}.get_usermay be sync or async; sync hooks run off the event loop in Django's sync executor, so the canonical token lookup Just Works:def get_user(request): token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip() return Token.objects.select_related("user").get(key=token).userWithout a hook, the middleware-provided lazy
request.useris materialized in a worker thread before the gate — with Django's DB-backed sessions, touching it on the event loop would raiseSynchronousOnlyOperation. The catalog views (ToolsViewandSkillsView) accept the samerequire_authenticated/get_userpair, so one policy covers the agent endpoint and the catalogs it advertises. 2. Parses the request body into aRunAgentInputviaAGUIAdapter.build_run_input(returning HTTP 400 with an error count, not the raw payload, on aValidationError). 3. Builds the per-requestAgent(via the factory orbuild_agent). 4. Wraps the agent in apydantic_ai.ui.ag_ui.AGUIAdapterand streams its encoded events as aStreamingHttpResponsewithContent-Type: text/event-stream,Cache-Control: no-cache, andX-Accel-Buffering: no.
Non-POST methods get 405 Method Not Allowed. The view marks itself as a
coroutine function so Django awaits it under ASGI; served over WSGI it emits a
one-time RuntimeWarning (SSE streaming needs ASGI). Frontend-declared tools in
the request are merged into the catalog by the adapter automatically.
get_urls returns the URL pattern(s) mounting a view
at a prefix (default agent/).
Skills¶
A SkillRegistry is an instance (like the tool
registry) holding a catalog of skills: pre-defined prompts the client
surfaces as chips and/or a /-command palette. Skills are data, not
callables — there is no @skill decorator; you register them imperatively:
from django_ag_ui import SkillRegistry
skills = SkillRegistry()
skills.add(
"summarise",
title="Summarise",
prompt="Summarise the {selection} for me.",
description="Condense the current selection.",
chip=True,
)
Each entry is a frozen SkillSpec
(name, title, prompt, optional description, send_immediately, chip).
add(...) is the convenience constructor; register(SkillSpec(...)) takes a
pre-built spec. The prompt is a static string that may contain
{placeholder}s the client fills from its skill context before sending.
send_immediately=True sends the prompt on pick instead of pre-filling the
input; chip=True also surfaces the skill as a chip (the palette lists all
skills regardless).
SkillRegistry.payload() returns the
client catalog as a list of camelCase dicts (name, title, prompt, and the
optional description, sendImmediately, chip keys, omitted when at their
default). It is served by SkillsView
(django_ag_ui.skills.skills_view.SkillsView) — a GET-only callable view — which
get_urls mounts at <prefix>skills/ when you pass
skills=:
The web component fetches this endpoint via its data-skills-url attribute.
Tool metadata catalog¶
Server-side tools — the @tool registry and (when
DRF_MCP_SERVER is set) the drf-mcp tools —
execute server-side, so their JSON Schema never reaches the browser. A client
therefore can't read an x-summary off the schema to label a tool-call card.
The tool catalog is the channel for those labels: a small read-only JSON
endpoint the web component fetches via its data-tools-url attribute and uses to
map a tool name → a friendly card label.
build_tool_catalog(registry)
builds the catalog as a list of entries, each
{"name", "summary", "description"?}:
summaryis always present, resolved from a fallback chain: registry tools use@tool(summary=…)(ToolSpec.summary) → a prettified tool name (query_model→"Query model"); drf-mcp tools usedisplay_name→title→ a prettified name.description(a longer blurb, e.g. for a tooltip) is included only when available —ToolSpec.descriptionfor registry tools, or drf-mcpdisplay_description→description.
Registry tools win on name collisions. The drf-mcp display_name /
display_description are drf-mcp's binding metadata (consumer-only, never on
the MCP wire), so the catalog surfaces friendly labels for those tools too.
ToolsView (django_ag_ui.ToolsView) — a GET-only callable view holding the
same ToolRegistry the agent uses — serves the
catalog. get_urls mounts it at <prefix>tools/ (named
django_ag_ui_tools) when you pass tools= the same registry:
Conversation persistence¶
By default the server is stateless: the conversation lives in the message
history the client posts on every turn. Persistence is opt-in via
CONVERSATION_STORE and modelled as a
pluggable Protocol, exactly like the audit logger.
ConversationStore is a runtime-checkable
Protocol with async load / save / delete, each taking the request. A
Conversation is a frozen record of a thread_id,
the AG-UI Message list (the wire shape, round-tripped verbatim), and an
owner_id for authorization scoping.
The implementations:
NullConversationStore— the default.loadreturnsNone;save/deleteare no-ops. The view treats this store as "persistence off" and adds no overhead — it skips wiring anon_completecallback entirely.DjangoSessionConversationStore— stores conversations in the Django session, namespaced bythread_idwithin the logged-in user's session (no migration). Durability spans that browser session.ModelConversationStore— an abstract base for model-backed (or any synchronous) store. It provides the async wrapping (sync_to_async) and per-request owner scoping; a subclass implements three synchronous row operations (_fetch,_store,_remove) against its own Django model. The package ships no concrete model on purpose, so it forces no migration — you define the model, its fields, and the owner relationship.
When a non-null store is configured, the view persists the run's full message
history when the run finishes streaming, scoped to the authenticated user
(owner_id).
Deferred to a later release
The plan's server-authoritative merge-by-id policy (reconciling stored
history with the posted messages so the client can only append, not rewrite,
past turns) and the owner-scoped GET conversations/<thread_id>/
rehydration endpoint are designed but not yet implemented in this
package. Today the store mirrors the run's messages on completion; the
client remains the source of truth for the posted history.
The drf-mcp toolset bridge¶
With the [drf-mcp] extra installed and
DRF_MCP_SERVER set, the view builds a
per-request DrfMcpToolset — a Pydantic-AI ExternalToolset that exposes a
djangorestframework-mcp-server registry's tools to the agent in-process,
with no network MCP hop.
- Tool schemas are sourced from drf-mcp's own
tools/list, so the agent sees the full advertisedinputSchema— including a selector tool's filter / ordering / pagination arguments and theadditionalPropertiespolicy — not just the input serializer's fields. - Execution routes through drf-mcp's async handler, so serializer validation and permissions are honoured exactly as over HTTP.
- The toolset carries the Django
request, synthesising anMCPCallContextwhose token isrequest.user, so the agent acts as the logged-in AG-UI user.
The bridge is imported lazily, only when DRF_MCP_SERVER is set, keeping
rest_framework_mcp an optional dependency.