Skip to content

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:

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:

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:

  1. 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 onto request.user (so tools, the drf-mcp bridge, and conversation ownership act as that user), and require_authenticated=True, which fails closed — anonymous requests get 401 with JSON {"error": "authentication required"}. get_user may 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).user
    

    Without a hook, the middleware-provided lazy request.user is materialized in a worker thread before the gate — with Django's DB-backed sessions, touching it on the event loop would raise SynchronousOnlyOperation. The catalog views (ToolsView and SkillsView) accept the same require_authenticated / get_user pair, so one policy covers the agent endpoint and the catalogs it advertises. 2. Parses the request body into a RunAgentInput via AGUIAdapter.build_run_input (returning HTTP 400 with an error count, not the raw payload, on a ValidationError). 3. Builds the per-request Agent (via the factory or build_agent). 4. Wraps the agent in a pydantic_ai.ui.ag_ui.AGUIAdapter and streams its encoded events as a StreamingHttpResponse with Content-Type: text/event-stream, Cache-Control: no-cache, and X-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=:

urlpatterns = [
    *get_urls(DjangoAGUIView(registry), prefix="agent/", skills=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"?}:

  • summary is 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 use display_nametitle → a prettified name.
  • description (a longer blurb, e.g. for a tooltip) is included only when available — ToolSpec.description for registry tools, or drf-mcp display_descriptiondescription.

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:

urlpatterns = [
    *get_urls(DjangoAGUIView(registry), prefix="agent/", tools=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. load returns None; save/delete are no-ops. The view treats this store as "persistence off" and adds no overhead — it skips wiring an on_complete callback entirely.
  • DjangoSessionConversationStore — stores conversations in the Django session, namespaced by thread_id within 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 advertised inputSchema — including a selector tool's filter / ordering / pagination arguments and the additionalProperties policy — 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 an MCPCallContext whose token is request.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.