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 — this gating is purely client-side and applies only to client-registered tools. Server-side tools (this package's @tool registry and drf-mcp-bridged tools) run mid-stream and are not gated: x-destructive reaches the LLM as a schema hint, but no server-side confirmation happens today. A real server-side gate is planned (a ToolGuard + typed ask_user mechanism). 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 composes an AuditCapability — a Pydantic-AI capability on the wrap_tool_execute lifecycle hook — that times and records every tool the agent runs: registry tools and composed toolsets (drf-mcp / spec / attachment / skill tools) alike. 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 — plus optional request/tenancy context (ip_address, filled by the view from the driving request; organization_id / target_type / target_id for custom sinks that know their tenancy and domain objects).

Recording is non-raising: a sink that throws is caught by the AuditCapability and logged to the django_ag_ui.audit Python logger, so a broken audit backend degrades to lost audit records, never a broken agent run.

Two implementations ship:

Projects supply their own (Sentry, Honeycomb, custom) by passing audit_logger= an instance — so a logger that needs constructor arguments just works.

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. Builds the run's dependenciesAgentDeps(user=request.user) — and passes them to the run. This is pydantic-ai's own seam for request-scoped values: tools, toolsets and capabilities read them off RunContext.deps rather than closing over the request.

    @tool(registry)
    def whoami(ctx: RunContext[AgentDeps]) -> str:
        """Report the acting user."""
        return str(ctx.deps.user)
    

    It is what makes spec tools act as the right user with nothing passed at the call site — SpecToolset's default extractor reads ctx.deps.user — and it is where a run's AG-UI state lands (deps.state), since AgentDeps satisfies pydantic-ai's StateHandler protocol. A request served without Django's auth middleware has no user attribute at all; that is an anonymous run (deps.user is None), not an error. 5. 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.

AGUIServer builds this view (plus its sub-views) from the registry and exposes a namespaced .urls tuple you mount at any prefix — see Mounting below.

Mounting

AGUIServer is the package's front door — one instance-configured object holding the registry, stores, and auth policy, mounted the django.contrib.admin site.urls way. Construct it once and include() its .urls:

from django.urls import path

from django_ag_ui import AGUIServer

agent = AGUIServer(registry, require_authenticated=True)

urlpatterns = [
    path("agent/", agent.urls),
]
  • The registry is passed once. The object builds the agent view and the tool catalog from it — no tools=registry echo.
  • You choose the mount point the Django way (path("<prefix>", agent.urls)); there is no prefix= argument.
  • .urls is namespaced. It returns the (patterns, app_name, namespace) triple path() mounts directly (like admin.site.urls — no include()), so the endpoints reverse as reverse("ag_ui:endpoint"), "ag_ui:tools", "ag_ui:skills", "ag_ui:threads", "ag_ui:thread", "ag_ui:attachments", "ag_ui:attachment", "ag_ui:transcribe". Two mounts don't collide; override the namespace with namespace="…".
  • Sub-views mount when their backend is active. The agent endpoint and its tool catalog always mount; skills mounts when a SkillRegistry is passed; threads / attachments / transcribe mount when their store/backend (resolved from settings by default, or passed explicitly) is not the Null one. A bare AGUIServer(registry) mounts only endpoint + tools.
  • One auth policy covers the whole mount. require_authenticated / get_user / authorize forward to every view the object builds.

Because the object holds its own registry and config, you can mount several with independent registries — one per surface — each namespaced separately.

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 AGUIServer mounts at <prefix>skills/ (named skills) when you pass skills=:

urlpatterns = [
    path("agent/", AGUIServer(registry, skills=skills).urls),
]

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. AGUIServer builds it from the registry you pass and mounts it at <prefix>tools/ (named tools) automatically — no extra argument:

urlpatterns = [
    path("agent/", AGUIServer(registry).urls),
]

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, plus list and rename for the thread drawer (see Thread history below), 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 the synchronous row operations (_fetch, _store, _remove, and the opt-in _list / _rename, which default to [] / no-op) 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. For a ready-made one, see the reference store.

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).

Thread history

The store also powers a chat-history drawer: a user's past conversations, each loadable, renamable, and deletable. Two Protocol methods back it, both owner-scoped:

  • list(*, request) returns ConversationMetathread_id, title, updated_at, previewmetadata only, no message bodies, so the drawer stays cheap. NullConversationStore returns []; DjangoSessionConversationStore enumerates the session's own threads (titles derived from the first user message, previews from the latest); ModelConversationStore._list defaults to [] until a subclass overrides it.
  • rename(thread_id, title, *, request) sets a thread's display title. The session store persists it (overriding the derived title); ModelConversationStore._rename is a no-op until overridden.

AGUIServer mounts ThreadsView automatically whenever the conversation store is active (a non-Null store, resolved from CONVERSATION_STORE by default or passed as conversation_store=), exposing them over HTTP for the web component's data-threads-url:

Route Method Action
<prefix>threads/ GET list the user's threads (metadata only)
<prefix>threads/<id>/ GET that thread's messages (server-side rehydration)
<prefix>threads/<id>/ PATCH rename ({"title": "..."})
<prefix>threads/<id>/ DELETE delete the thread

Every operation is scoped to the acting user — a thread owned by someone else reads as 404, never another user's history — and the view carries the same require_authenticated / get_user auth seam as DjangoAGUIView.

A ready-made durable store

For cross-device, per-user history without writing your own model, opt into the django_pydantic_agent.contrib.store app: add "django_pydantic_agent.contrib.store" to INSTALLED_APPS, run migrate, and set CONVERSATION_STORE to django_pydantic_agent.contrib.store.default_conversation_store.DefaultConversationStore. It ships a StoredConversation model and a ModelConversationStore subclass with denormalised title / preview / updated_at columns so the thread list is a single cheap query. Projects that don't opt in get no model and no migration.

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) is designed but not yet implemented; today the store mirrors the run's messages on completion and the client remains the source of truth for the posted history. (The owner-scoped rehydration endpoint is now shipped — GET <prefix>threads/<id>/ above.)

File uploads

A user can attach files to a conversation — drop a PDF or image into the composer, send a message, and let the agent read it. The design keeps the AG-UI wire vanilla: files upload out-of-band to their own endpoint and travel as lightweight refs (id / name / mime / size), never as base64 on the message stream — the same principle the tool metadata catalog uses to keep schemas off the wire.

The lifecycle:

  1. The composer uploads each file (multipart POST <prefix>attachments/) and gets back an AttachmentRef — a durable handle, not bytes.
  2. The user sends a message carrying the refs.
  3. When the model needs a file's contents, it calls the built-in read_attachment(attachment_id) tool, which resolves the bytes server-side, owner-scoped to the acting user.

The store

AttachmentStore is the persistence seam, set via ATTACHMENT_STORE. Every method is async and owner-scoped — one user's id can never resolve another's file, the security boundary for the feature. The default NullAttachmentStore keeps uploads off (410 Gone); subclass the abstract ModelAttachmentStore for your own model, or opt into the ready-made durable store which keeps bytes in Django Storage (filesystem by default, S3/GCS via STORAGES).

The endpoints

AGUIServer mounts AttachmentsView automatically whenever the attachment store is active (a non-Null store, resolved from ATTACHMENT_STORE by default or passed as attachment_store=):

  • POST <prefix>attachments/ — multipart upload under the file field; validates size and type server-side, then returns 201 with the ref JSON.
  • GET <prefix>attachments/<id>/ — stream the bytes back (owner-checked), as an attachment with X-Content-Type-Options: nosniff so an uploaded text/html can't execute as a same-origin page; missing / cross-owner → 404.
  • DELETE <prefix>attachments/<id>/ — drop the attachment (204).

All owner-scoped and open by default like the catalog views — construct AttachmentsView yourself with require_authenticated / get_user to lock it down whenever the agent endpoint is. The web component reads data-attachments-url to drive the composer's upload tray.

Cancelling a run

AG-UI has no server-side cancel route. A run is one streaming HTTP request; the client cancels it by aborting that request, and the server observes a disconnect. The view handles that disconnect explicitly rather than leaving the teardown to garbage collection:

  • Provider teardown is guaranteed. The view keeps a reference to the innermost event generator — the one whose context manager owns the model provider's streaming request — and closes it when the disconnect surfaces, so no orphaned upstream generation keeps running (or billing) after the client stopped listening.
  • The partial exchange is persisted. With a non-null CONVERSATION_STORE configured, the truncated conversation — the client-posted history plus whatever assistant text and completed tool calls streamed before the disconnect — is saved with the same thread/owner scoping as a completed run, so a durable thread reflects reality. Partially streamed tool calls are dropped (half a JSON arguments string is not a usable record). With the default NullConversationStore, nothing is saved.
  • The cancellation is audited. The configured AuditLogger receives a run-level AuditEvent with tool_name="agent.run", success=False, and an error starting with "cancelled:" — distinguishable from tool failures in logs/Sentry without widening the protocol. duration_ms measures run start → cancellation.
  • Cancellation is never swallowed. The guard re-raises after observing; failures inside the persist/audit step are logged and do not replace the cancellation.

There is no setting to turn this off — cancellation handling is transport-level, and partial persistence simply follows the store you already configured (matching the client, which keeps the partial assistant bubble).

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 toolset (an AbstractToolset subclass) 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 (via its public MCPServer.list_tools), 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 public MCPServer.acall_tool (its in-process transport surface, drf-mcp 0.9+), so serializer validation and permissions are honoured exactly as over HTTP — without reaching into handler internals.
  • The toolset hands the Django request and request.user to those methods, 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.