Skip to content

API reference

Autodoc of the public surface re-exported from django_ag_ui. Everything below is importable directly, e.g. from django_ag_ui import ToolRegistry.

Registry

ToolRegistry

An ordered, named collection of server-side tools.

State lives on the instance: a DjangoAGUIView holds one registry; tests build a fresh registry per scenario. The registry derives a JSON Schema for each tool at registration (including the x-destructive / x-category extensions) and can dispatch sync or async callables.

register

register(spec: ToolSpec) -> ToolBinding

Register spec and return its binding.

Raises:

Type Description
ValueError

when spec.name is already registered.

get

get(name: str) -> ToolBinding

Return the binding for name or raise KeyError.

call

call(name: str, arguments: dict[str, Any]) -> Any

Dispatch a sync call to the registered tool.

Refuses coroutine functions to avoid silently returning an un-awaited coroutine. Use :meth:acall for async tools.

acall async

acall(name: str, arguments: dict[str, Any]) -> Any

Dispatch an async call; transparently awaits sync callables.

tool

tool(
    registry: ToolRegistry,
    *,
    name: str | None = None,
    description: str | None = None,
    destructive: bool = False,
    category: ToolCategory = ToolCategory.OTHER,
    confirm: str | None = None,
    summary: str | None = None,
) -> Callable[[F], F]

Register the decorated callable on registry as a tool.

The tool's name defaults to the function name; its description defaults to the first paragraph of the function's docstring. Both can be overridden via the keyword arguments. confirm supplies a human-readable confirmation prompt for a destructive tool (surfaced as x-confirm); summary a short display label (surfaced as x-summary).

build_input_schema

build_input_schema(
    fn: Callable[..., Any],
    *,
    destructive: bool = False,
    category: ToolCategory = ToolCategory.OTHER,
    confirm: str | None = None,
    summary: str | None = None,
) -> dict[str, Any]

Derive a JSON Schema object from fn's parameters.

Supports the primitive types used by the built-in tool surface: str, int, float, bool, list[T], dict[str, Any], and X | None unions. Anything richer falls back to an empty schema fragment (no type constraint), which is still wire-valid JSON Schema.

The destructive flag is stamped at the schema root as x-destructive; category as x-category; confirm (when given) as x-confirm. AG-UI passes these extensions through verbatim, and frontends read x-destructive / x-confirm to gate execution behind a confirmation step.

ToolSpec dataclass

Canonical declaration of a server-side tool.

A ToolSpec bundles the callable with the metadata the registry needs to expose it to a Pydantic-AI agent and to a frontend: a stable name, a human-facing description, a destructive risk flag, and a coarse category.

name instance-attribute

name: str

Stable identifier exposed to the agent. Must be unique within a :class:~django_pydantic_agent.registry.tool_registry.ToolRegistry.

fn instance-attribute

fn: Callable[..., Any]

The Python callable that implements the tool. Must have typed parameters; the registry derives a JSON Schema from the signature.

description instance-attribute

description: str

User-facing summary shown to the agent. The first line is what most clients display.

destructive class-attribute instance-attribute

destructive: bool = False

If True, calling this tool may mutate state. The registry stamps x-destructive: true into the tool's JSON Schema so frontends can gate it behind a confirmation step.

category class-attribute instance-attribute

category: ToolCategory = OTHER

Coarse capability grouping. Surfaced as x-category in the JSON Schema.

confirm class-attribute instance-attribute

confirm: str | None = None

Optional human-readable confirmation prompt for a destructive tool (e.g. "Activate this project?"). Stamped as x-confirm so the frontend can show it instead of a generic "Run ?".

summary class-attribute instance-attribute

summary: str | None = None

Optional short label for the tool (e.g. "Query orders"). Stamped as x-summary so the frontend shows it on the tool-call card instead of the raw tool name.

ToolBinding dataclass

A registered tool plus the JSON Schema derived from its signature.

The schema is computed once at registration (including the x-destructive / x-category extensions) and carried alongside the spec so tool listings don't re-introspect on every request.

ToolCategory

Bases: str, Enum

Coarse grouping for a tool, surfaced to the agent and the UI.

Categories are advisory metadata: they let a frontend group tools, let a system prompt reason about capability classes, and let a project apply category-wide policy. They do not by themselves gate execution — that is the destructive flag's job.

X_DESTRUCTIVE_KEY module-attribute

X_DESTRUCTIVE_KEY = 'x-destructive'

X_CATEGORY_KEY module-attribute

X_CATEGORY_KEY = 'x-category'

X_CONFIRM_KEY module-attribute

X_CONFIRM_KEY = 'x-confirm'

X_SUMMARY_KEY module-attribute

X_SUMMARY_KEY = 'x-summary'

Skills

SkillRegistry

An ordered, named collection of :class:SkillSpecs.

State lives on the instance (like :class:~django_pydantic_agent.registry.tool_registry.ToolRegistry). :meth:payload produces the JSON-serialisable catalog the frontend consumes (camelCase keys, optional fields omitted when default).

register

register(spec: SkillSpec) -> SkillSpec

Register spec; raise ValueError if the name is taken.

add

add(
    name: str,
    title: str,
    prompt: str,
    *,
    description: str | None = None,
    send_immediately: bool = False,
    chip: bool = False,
) -> SkillSpec

Construct a :class:SkillSpec and register it (convenience).

payload

payload() -> list[dict[str, Any]]

The client catalog: a list of skill dicts with camelCase keys.

SkillSpec dataclass

A pre-defined prompt offered to the user (a "skill").

Serialised into the client catalog the frontend surfaces as chips and/or the /-command palette. Skills are data, not callables — the prompt is a static string (it may contain {placeholder}s the client fills from its skill context before sending).

name instance-attribute

name: str

Stable id; the /token in the palette. Unique within a registry.

title instance-attribute

title: str

Label shown in chips and the palette.

prompt instance-attribute

prompt: str

The prompt inserted (or sent). May contain {placeholder}s.

description class-attribute instance-attribute

description: str | None = None

Optional secondary line shown in the palette.

send_immediately class-attribute instance-attribute

send_immediately: bool = False

Send on pick instead of pre-filling the input. Surfaced as sendImmediately.

chip class-attribute instance-attribute

chip: bool = False

Also surface as a chip (the palette shows all skills regardless).

SkillsView

A read-only endpoint returning a :class:SkillRegistry's client catalog.

A callable instance (like :class:~django_ag_ui.agent.agui_view.DjangoAGUIView) so it can hold the registry and a project can mount several. GET returns the JSON skill list the frontend fetches via data-skills-url.

Skill prompts can encode internal workflows — worth gating. The view carries the same authentication seam as DjangoAGUIView (require_authenticated / get_user, sync or async hooks), so one policy can cover the agent endpoint and its catalogs. Defaults stay open for backwards compatibility — lock the catalog down whenever the agent endpoint is locked down.

Agent and view

AGUIServer

One config object that mounts an AG-UI endpoint and its sub-views.

The Django-idiomatic front door for the package — the admin.site idiom and the mirror of drf-mcp's MCPServer. Construct it once with the tool registry (plus optional stores / auth), then mount its namespaced :attr:urls with include()::

from django_ag_ui import AGUIServer

agent = AGUIServer(registry, require_authenticated=True)

urlpatterns = [
    path("agent/", agent.urls),
]
# reverse("ag_ui:endpoint") · "ag_ui:tools" · "ag_ui:threads" · ...

The registry is passed once: the object builds the agent view (:class:~django_ag_ui.agent.agui_view.DjangoAGUIView) and the read-only tool catalog (:class:~django_ag_ui.agent.tools_view.ToolsView) from it — no tools=registry echo. The mount point is the consumer's to choose the Django way (path("<prefix>", agent.urls)); there is no prefix=.

What gets mounted. The agent endpoint (endpoint) and its tool catalog (tools) always mount. The persistence sub-views mount only when their backend is active:

  • skills — when a :class:~django_ag_ui.skills.skill_registry.SkillRegistry is passed (skills/, GET JSON for data-skills-url).
  • threads / thread — when the conversation store is not a :class:~django_pydantic_agent.persistence.null_conversation_store.NullConversationStore (threads/ + threads/<id>/ for the history drawer's data-threads-url).
  • attachments / attachment — when the attachment store is not a :class:~django_pydantic_agent.persistence.null_attachment_store.NullAttachmentStore (attachments/ + attachments/<id>/ for the composer's data-attachments-url).
  • transcribe — when the transcription backend is not a :class:~django_ag_ui.persistence.null_transcription_backend.NullTranscriptionBackend (transcribe/ for the mic's data-transcribe-url).
  • resume / fork / runs — when a step_store is configured (resume/<run_id>/ + fork/<run_id>/ seed a new run from a prior run's last continuable snapshot; runs/ indexes what may be resumed).

conversation_store / attachment_store / transcription_backend default to the DJANGO_AG_UI settings-resolved backend (the same one the agent view persists to), so configuring a store in settings mounts its sub-view automatically; pass an instance to override. Since the defaults resolve to the Null* backends, a bare AGUIServer(registry) mounts only the agent endpoint and its tool catalog — the same surface the old get_urls(view) produced.

Authentication seam. require_authenticated / get_user / authorize are forwarded to every view this object builds — the agent endpoint and all sub-views — so one policy locks down the whole mount (401 for anonymous when require_authenticated, 403 from an authorize predicate, get_user establishing the acting user). The agent view's model / instructions / audit_logger / csrf_exempt fall back to settings when not passed. Everything defaults open for backwards compatibility; the endpoints are unauthenticated until you set these.

Anonymous scoping caveat. With the endpoints left open (the default) and a model-backed store, an anonymous request has no owner id. The reference contrib stores refuse anonymous thread / attachment operations unless ALLOW_ANONYMOUS is set (in which case they bucket per browser session) — so pass require_authenticated=True (or a get_user hook) whenever the store persists, rather than relying on owner scoping to isolate anonymous visitors from one another.

Spec tools. service_specs takes a name -> spec mapping or a spec registry — drf-services 0.27's SpecRegistry, the single declaration site for a project exposing the same specs over more than one transport, so this endpoint reads the same source an MCP server and the HTTP views do::

AGUIServer(registry, service_specs=spec_registry.by_tag("public"))

Either shape is normalised once, here, into a plain dict; a filtered view (by_tag / subset) is itself a registry, so two endpoints can be given different projections with no shared state. Requires the django-ag-ui[spec-tools] extra.

Per-run dependencies. deps_factory is a request -> AgentDeps callable replacing the default, which binds only the acting user. Use it to carry project-specific per-run context (a tenant, a feature-flag snapshot) on an AgentDeps subclass, or to seed AgentDeps.state with a Pydantic model — the only way to have AG-UI's inbound shared state validated, since pydantic-ai validates it against type(deps.state). Whatever it returns reaches every tool, toolset and capability as ctx.deps.

Durable step persistence. step_store is a factory — a request -> StepStore callable, not a shared store instance, because the pydantic-ai-harness step-store protocol carries no request, so the store binds one and is built per run. When set, every run attaches a StepPersistence capability that records an owner-scoped run / event / snapshot / tool-effect ledger through that store. Pass :class:~django_pydantic_agent.contrib.store.default_step_store.DefaultStepStore (its constructor is the request -> StepStore factory) for the reference model-backed store, or any such callable. Requires the django-ag-ui[harness] extra. Configuring it also mounts three owner-scoped endpoints: resume/<run_id>/ and fork/<run_id>/, which seed a new run with a prior run's last continuable snapshot, and runs/, which indexes the user's runs so a client can discover what it may resume. Without that index a client can only continue a run whose id it still holds — ruling out resuming after a page reload or from another device, which is most of what durable persistence is for.

Namespacing. :attr:urls returns the (patterns, app_name, namespace) triple path() mounts directly (like admin.site.urls — no include()), so endpoint names are namespaced (namespace, default "ag_ui") and multiple mounts don't collide — reverse("ag_ui:endpoint").

urls property

urls: tuple[list[URLPattern], str, str]

The namespaced (patterns, app_name, namespace) triple path() mounts.

Mounts directly at any prefix — path("agent/", server.urls), no include() — exactly like admin.site.urls. Reverse the endpoints within the namespace: reverse("<namespace>:endpoint"), "<namespace>:tools", "<namespace>:skills", "<namespace>:threads", "<namespace>:thread", "<namespace>:attachments", "<namespace>:attachment", "<namespace>:transcribe", "<namespace>:resume", "<namespace>:fork".

DjangoAGUIView

An async Django view that serves an AG-UI endpoint.

Bridges a Django HttpRequest to Pydantic-AI's AGUIAdapter without Starlette: it parses the posted RunAgentInput, builds a Pydantic-AI Agent from the server-side tool registry, and returns a StreamingHttpResponse of AG-UI events (Server-Sent Events). Frontend tools declared in the request are merged by the adapter automatically.

The view is a callable instance, so configuration lives on self and a project can mount several with independent registries. model, instructions, and audit_logger fall back to the DJANGO_AG_UI settings when not passed explicitly (tests inject a TestModel).

Authentication is the host's responsibility. Tools (and the drf-mcp bridge) act as request.user; if your middleware hasn't authenticated the request, that is AnonymousUser — a data-exposure footgun. Pass require_authenticated=True to fail closed (401 for unauthenticated requests), and/or a get_user(request) hook to establish the user (e.g. from a token) before tools run. get_user may be sync or async; a sync hook runs off the event loop, so a plain ORM token → User lookup (Token.objects.select_related("user").get(key=...).user) is fully supported. A hook that raises propagates as an unhandled error (500) — return AnonymousUser (or None) for a clean 401 instead.

CSRF: the view defaults to csrf_exempt=True because AG-UI clients typically authenticate via headers (Bearer / API key), where CSRF does not apply. If your deployment authenticates with session cookies, pass csrf_exempt=False and send the CSRF token from the client — tools act as request.user, so a cookie-auth endpoint without CSRF protection lets any third-party page drive the agent as the logged-in user (mitigated, not eliminated, by Django's default SameSite=Lax cookie).

AgentSession

Per-run orchestration between the HTTP transport and the agent.

Owns everything one AG-UI run needs after the transport has authenticated the request, parsed the RunAgentInput, and built the agent — and before the response object exists: the AGUIAdapter, the composed event stream (native → transformed → reasoning-filtered → encoded → disconnect-guarded), completed-run persistence, and the cancelled-run persist + audit path.

Splitting it from :class:~django_ag_ui.agent.agui_view.DjangoAGUIView makes the streaming pipeline testable without a StreamingHttpResponse (drive :meth:stream directly) and keeps the SSE transport swappable — a future WebSocket transport reuses the session unchanged.

stream

stream() -> AsyncIterator[str]

The encoded AG-UI event stream for this run, disconnect-guarded.

Composed by hand (rather than adapter.run_stream) so the session keeps a reference to the native event stream — the innermost generator, whose context manager owns the provider's streaming request. On client disconnect the guard closes it explicitly, then persists the partial exchange and audits the cancellation.

ToolsView

A read-only endpoint returning the agent's server-tool catalog (GET, JSON).

A callable instance (like :class:~django_ag_ui.agent.agui_view.DjangoAGUIView) holding the same :class:~django_pydantic_agent.registry.tool_registry.ToolRegistry the view uses. GET returns the :func:build_tool_catalog list the web component fetches via data-tools-url to label tool-call cards for server-side tools (whose schema never reaches the browser).

The catalog names every server tool the agent can wield — an inventory worth gating. The view carries the same authentication seam as DjangoAGUIView (require_authenticated / get_user, sync or async hooks), so one policy can cover the agent endpoint and its catalogs. Defaults stay open for backwards compatibility — lock the catalog down whenever the agent endpoint is locked down.

build_agent

build_agent(registry: ToolRegistry, config: AgentConfig) -> Agent[AgentDeps, Any]

Build a Pydantic-AI Agent from a registry and an :class:AgentConfig.

Each registry tool is registered as a plain Pydantic-AI tool. When config.audit_logger is set, an :class:AuditCapability on the wrap_tool_execute lifecycle hook 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 AG-UI RunAgentInput are merged automatically by the adapter and are not registered here.

model_settings / retries tune the model; toolsets and capabilities compose external Pydantic-AI toolsets/capabilities (e.g. an MCP-client toolset) alongside the registry tools, so the agent can reach beyond the registered set. tool_guard, when enabled, adds a :class:ToolGuard that flips destructive tools to require approval.

The agent is typed Agent[AgentDeps, ...], so a run must be given :class:AgentDeps (the transport builds one per run and passes deps=).

Capabilities are composed order-independently: each declares its position via get_ordering (audit is outermost, the guard is orthogonal), and pydantic-ai's CombinedCapability topologically sorts them — so the list built here needn't be pre-ordered.

AgentConfig dataclass

Resolved construction parameters for a Pydantic-AI Agent.

Bundles everything :func:~django_pydantic_agent.agent.agent_factory.build_agent needs so the call site passes one record instead of a long keyword list. A transport resolves these from its own configuration and hands the record down; toolsets and capabilities arrive already resolved to instances (never dotted paths — this substrate resolves nothing from settings).

model instance-attribute

model: Any

The Pydantic-AI model (a model string or Model instance).

instructions class-attribute instance-attribute

instructions: str | None = None

System/instructions prompt for the agent.

audit_logger class-attribute instance-attribute

audit_logger: AuditLogger | None = None

Wraps every server-side tool call for timing + success/failure records. None means no auditing (a no-op logger).

audit_ip_address class-attribute instance-attribute

audit_ip_address: str | None = None

Client IP stamped onto every audit event this agent records (the view fills it from the driving request). None leaves the field unset.

model_settings class-attribute instance-attribute

model_settings: dict[str, Any] | None = None

Pydantic-AI ModelSettings (temperature, max_tokens, …).

retries class-attribute instance-attribute

retries: int | None = None

Default tool/output retry budget.

toolsets class-attribute instance-attribute

toolsets: Sequence[Any] | None = None

Extra Pydantic-AI toolsets composed alongside the registry tools.

capabilities class-attribute instance-attribute

capabilities: Sequence[Any] | None = None

Pydantic-AI capabilities passed to the Agent.

tool_guard class-attribute instance-attribute

tool_guard: ToolGuardConfig | None = None

Server-side destructive-tool approval policy. When set and enabled, :func:~django_pydantic_agent.agent.agent_factory.build_agent composes a :class:~django_pydantic_agent.policy.guard.tool_guard.ToolGuard capability built from the registry's destructive tools. None (or disabled) leaves the agent ungated.

AgentFactoryFn

Bases: Protocol

The escape-hatch signature for a transport's agent_factory= argument.

A callable matching this shape fully replaces the built-in :func:~django_pydantic_agent.agent.agent_factory.build_agent, giving a project complete control over Pydantic-AI Agent construction (custom model providers, output types, toolsets, instrumentation, …). It receives the server-side tool registry and that transport's resolved config object.

config is deliberately untyped here: this substrate reads no settings and owns no settings namespace, so the second argument is whatever configuration record the calling transport resolved — AGUIConfig for the AG-UI transport, its own equivalent for another. Each transport documents the concrete type its users receive.

The returned agent must be typed Agent[AgentDeps, ...] — build it with deps_type=AgentDeps. Transports hand every run an :class:~django_pydantic_agent.agent.types.agent_deps.AgentDeps, and that is how the acting user reaches spec tools (ctx.deps.user) and how AG-UI state reaches deps.state. A factory that omits deps_type produces an agent whose tools see no user.

DEFAULT_SYSTEM_PROMPT module-attribute

DEFAULT_SYSTEM_PROMPT = "You are an assistant embedded in a web application. You can call tools to read data and to drive the user interface on the user's behalf. Prefer the most specific tool available. For a destructive action, call the tool directly with the right arguments — the interface shows the user an explicit confirmation before it runs, so do NOT ask for confirmation in text or wait for the user to say yes. Briefly state what you are doing. When the user refers to something by name, use a listing or search tool's arguments to find it and then act on the result — don't stop after the lookup. Treat 'open', 'go to', or 'show me' as a request to navigate. Always finish your turn with a short reply or a completed action — never an empty turn. Keep replies concise."

Configuration

AGUIConfig dataclass

An endpoint's resolved scalar configuration.

Every field is already resolved — there is no "unset" state and no settings lookup left to do. :class:AGUIServer builds one in __init__ (via :func:~django_ag_ui.config.build_ag_ui_config.build_ag_ui_config, which reads DJANGO_AG_UI) and threads it to the agent view and every sub-view.

That indirection is the point. Read at request time, these values could only ever be global, so two AG-UI endpoints in one project could not differ on any of them — an /internal/agent and a /public/agent were forced to share one tool-guard policy, one retry budget, one upload cap.

Replaces the old AppSettings, which mixed these scalars with dotted paths to collaborators. Those are now constructor arguments taking real objects (toolsets=, capabilities=, conversation_store=, …), so what remains here is only values.

Do not construct this directly to override a field: a partially-specified config would silently discard the project's own DJANGO_AG_UI values. Use build_ag_ui_config(retries=3), which layers overrides over the settings.

model instance-attribute

model: Any

The Pydantic-AI model: a "provider:name" string (e.g. "anthropic:claude-sonnet-4.6") or a pre-built Model instance. May be None here; the agent factory raises a clear error if it is still unset when an agent is actually built.

api_key instance-attribute

api_key: str | None

API key handed to the provider when model is a "provider:name" string, so the key comes from settings rather than the environment. Ignored when a provider is passed or model is already a Model.

system_prompt instance-attribute

system_prompt: str | None

Override for the agent's default system prompt. None uses :data:DEFAULT_SYSTEM_PROMPT.

model_settings instance-attribute

model_settings: dict[str, Any] | None

Pydantic-AI ModelSettings (e.g. {"temperature": 0.2}) passed straight to the Agent. None leaves the model defaults untouched.

retries instance-attribute

retries: int | None

Default tool/output retry budget passed to the Agent. None uses Pydantic-AI's default.

attachment_max_bytes instance-attribute

attachment_max_bytes: int

Maximum accepted upload size in bytes (server-authoritative). 0 disables the cap.

attachment_allowed_types instance-attribute

attachment_allowed_types: tuple[str, ...]

Allowed (client-declared) content types for uploads. Empty accepts any.

manage_system_prompt instance-attribute

manage_system_prompt: str

Who owns the system prompt on the wire: "server" (the agent's prompt is authoritative and a client-posted system message is ignored) or "client". instructions are always server-side regardless.

allow_uploaded_files instance-attribute

allow_uploaded_files: bool

Whether UploadedFile references in client-submitted messages are honoured. False drops them with a warning before they reach the agent.

forward_reasoning instance-attribute

forward_reasoning: bool

Whether to forward a reasoning model's chain-of-thought to the client as AG-UI reasoning events — a pure adapter pass-through, only emitted if a thinking budget is enabled via model_settings.

transcription_max_bytes instance-attribute

transcription_max_bytes: int

Maximum accepted audio-clip size in bytes (server-authoritative). 0 disables the cap.

transcription_allowed_types instance-attribute

transcription_allowed_types: tuple[str, ...]

Allowed (client-declared) content types for voice clips. Empty accepts any.

thread_list_limit instance-attribute

thread_list_limit: int

Maximum threads the index endpoint returns in one call. A larger ?limit is clamped to this ceiling.

tool_guard instance-attribute

tool_guard: ToolGuardConfig

Server-side destructive-tool approval policy. When enabled, a ToolGuard capability flips destructive tools to require the AG-UI approval interrupt.

build_ag_ui_config

build_ag_ui_config(
    *,
    model: Any = None,
    api_key: str | None = None,
    system_prompt: str | None = None,
    model_settings: dict[str, Any] | None = None,
    retries: int | None = None,
    attachment_max_bytes: int | None = None,
    attachment_allowed_types: tuple[str, ...] | list[str] | None = None,
    manage_system_prompt: str | None = None,
    allow_uploaded_files: bool | None = None,
    forward_reasoning: bool | None = None,
    transcription_max_bytes: int | None = None,
    transcription_allowed_types: tuple[str, ...] | list[str] | None = None,
    thread_list_limit: int | None = None,
    tool_guard: ToolGuardConfig | None = None,
) -> AGUIConfig

Resolve an :class:AGUIConfig from DJANGO_AG_UI, applying overrides.

The single place the scalar settings are read. :class:AGUIServer calls this once in __init__; nothing reads these settings per request, which is what lets two endpoints in one project hold different values.

Every argument is None by default, meaning "take it from settings". Pass one to override just that field for this endpoint::

AGUIServer(registry, config=build_ag_ui_config(retries=3))

Use this rather than constructing :class:AGUIConfig directly — it is what layers your overrides over the project's settings instead of discarding them.

Policy and audit

AuditLogger

Bases: Protocol

Sink for tool-invocation records.

Implementations are free to drop, sample, or forward events. The package ships a no-op default (NullAuditLogger) and a logging-backed implementation (LoggingAuditLogger); projects supply their own by passing it to their transport's audit_logger= argument.

AuditCapability

Bases: AbstractCapability[Any]

Records every tool execution to an :class:AuditLogger sink.

A Pydantic-AI capability on the wrap_tool_execute lifecycle hook, so it times and records every tool the agent runs — registry tools, drf-mcp / spec-toolset bridges, attachment and skill tools alike — where the old per-tool wrapper saw only the registry.

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

ip_address / organization_id pre-fill the matching :class:AuditEvent fields for every event this capability records — the view passes the client IP; a multi-tenant host can pass its org scope.

get_ordering

get_ordering() -> CapabilityOrdering

Pin audit as the outermost capability in the chain.

Audit is the observability layer: its wrap_tool_execute should surround every other capability's execution hooks so it records the tool regardless of what else composes the run. Declaring the position here (rather than relying on list order at the build_agent call site) makes composition deterministic once a second capability — e.g. :class:~django_pydantic_agent.policy.guard.tool_guard.ToolGuard — joins the chain: pydantic-ai's CombinedCapability topologically sorts by these constraints, so audit stays outermost no matter the insertion order.

AuditEvent dataclass

A single tool invocation as seen by the audit logger.

Arguments are stored as a string (typically JSON-encoded) to keep audit records cheap to serialize and to discourage retention of sensitive raw values.

One run-level record rides this shape: when a client disconnects mid-run (cancel/stop), the view records tool_name="agent.run" with success=False and an error starting with "cancelled:", so cancelled runs are distinguishable in audit sinks without widening the AuditLogger protocol.

organization_id class-attribute instance-attribute

organization_id: str | None = None

Multi-tenant scope of the acting user, when the sink can derive one. None at this layer — a custom :class:AuditLogger fills it from its own tenancy model.

target_type class-attribute instance-attribute

target_type: str | None = None

Kind of domain object the call acted on ("order", "user", …). None at this layer — tool args are domain-opaque here; a custom sink that knows its tools can classify them.

target_id class-attribute instance-attribute

target_id: str | None = None

Identifier of the acted-on object, paired with target_type.

ip_address class-attribute instance-attribute

ip_address: str | None = None

Client IP of the request that drove the run, when the view knows it.

NullAuditLogger

Discards every event. The default when no audit logger is configured.

LoggingAuditLogger

Writes audit events to the Python logging framework.

Successful invocations log at INFO; failures log at WARNING with the error message included.

Conversation persistence

ConversationStore

Bases: Protocol

Pluggable server-side persistence for AG-UI conversations.

Passed to AGUIServer(conversation_store=...). The package ships a no-op default (NullConversationStore — the server stays stateless) and a session-backed implementation; projects supply their own (a DB model, Redis, …). All methods are async so an implementation can use the async ORM or a network backend.

Threads key by (owner_id, thread_id), so two endpoints sharing a store share one user's thread list. Wrap with :class:~django_pydantic_agent.persistence.scoped_conversation_store.ScopedConversationStore to partition them.

list returns owner-scoped metadata only (no message bodies) for the thread drawer, capped at limit rows (None = the store's own default); a store that can't enumerate (the stateless default) returns an empty list. exists is a cheap owner-scoped presence check — no message body loaded — so a rename / probe doesn't deserialize a whole thread just to 404. rename sets a thread's display title (a store that can't persist one is a no-op).

Conversation dataclass

A persisted conversation, keyed by thread_id.

messages are JSON-serialisable message records whose shape the calling transport owns — this substrate persists and returns them verbatim and never interprets them. The AG-UI transport stores its own wire Message shape (so client message ids survive a round trip untouched); another transport stores its own. That is what keeps the storage contract neutral: the core supplies owner scoping, async plumbing and the models, while the message vocabulary stays with whoever speaks it.

owner_id scopes the conversation to a user for authorization.

ConversationMeta dataclass

Lightweight metadata for one conversation — the thread-drawer row shape.

Returned by :meth:ConversationStore.list <django_pydantic_agent.ConversationStore> so a thread list stays cheap: it carries no message bodies. title defaults to a truncation of the first user message (unless a store records an explicit rename); preview is a one-line excerpt of the latest message; updated_at is when the conversation last changed, or None when the store doesn't track it. owner_id scopes the conversation to a user and is not surfaced on the wire.

NullConversationStore

The default store: no-op, keeping the server stateless.

load always returns None and save / delete do nothing, so the conversation lives entirely in the client's posted history (today's behaviour). The view treats this store as "persistence off".

ScopedConversationStore

Partition another :class:ConversationStore by a scope name.

Stores key threads by (owner_id, thread_id). Two AG-UI endpoints sharing one store therefore share one user's thread list: a conversation started at /internal/agent appears in /public/agent's history drawer and can be resumed there — under the public agent's model, tools and guard policy.

Wrapping fixes that without a migration::

internal = AGUIServer(
    registry,
    conversation_store=ScopedConversationStore(store, scope="internal"),
)
public = AGUIServer(
    registry,
    conversation_store=ScopedConversationStore(store, scope="public"),
)

Prefixing the thread id is deliberate. A scope column would mean a migration and a breaking change to the :class:~django_pydantic_agent.persistence.types.conversation_store.ConversationStore protocol — which every custom store implements — for a partition the id space already expresses. This composes with any implementation, third-party ones included.

Opt in explicitly. :class:AGUIServer deliberately does not wrap by itself: doing so from its namespace would silently orphan the whole thread history of an existing single-endpoint project the moment it set one.

The scope is invisible on the wire — thread ids are echoed back to the client unchanged; only the storage key carries the prefix.

list async

list(*, request: HttpRequest, limit: int | None = None) -> ConversationMetaList

This scope's threads only, with storage keys translated back.

limit is applied by the inner store before this filter, so a busy sibling scope can crowd out rows. Acceptable for a drawer that is capped anyway; a store that needs exact per-scope paging should partition at the query, not by wrapping.

DjangoSessionConversationStore

Conversation persistence in the Django session (no migration).

Conversations are namespaced by thread_id within the logged-in user's session, so scoping to the user is implicit and durability spans that user's browser session. The batteries-included server-side store; for cross-device or audited persistence, supply a model-backed store instead.

ModelConversationStore

Bases: ABC

Abstract base for a model-backed (or any sync) ConversationStore.

Provides the async wrapping and per-request owner scoping; a subclass implements the three synchronous row operations against its own Django model (cross-device, auditable persistence). Kept model-agnostic on purpose — the package ships no concrete model so it forces no migration; consumers define the model, its fields, and the owner relationship.

Example::

class MyStore(ModelConversationStore):
    def _fetch(self, thread_id, owner_id):
        row = MyConversation.objects.filter(
            thread_id=thread_id, owner_id=owner_id,
        ).first()
        return None if row is None else Conversation(...)
    def _store(self, conversation, owner_id): ...
    def _remove(self, thread_id, owner_id): ...

ThreadsView

Owner-scoped thread index endpoint for the chat-history drawer (async, JSON).

Mounted by :func:~django_ag_ui.get_urls with threads=<store> over the same :class:~django_ag_ui.ConversationStore the agent view uses:

  • GET <prefix>threads/ → the user's threads, metadata only ({"threads": [...]});
  • GET <prefix>threads/<id>/ → that thread's messages ({"thread_id", "messages"});
  • PATCH <prefix>threads/<id>/ → rename (body {"title": "..."});
  • DELETE <prefix>threads/<id>/ → delete the thread (204).

Every operation is scoped to the acting user: the store filters by owner, so a thread owned by another user simply isn't found (404) — never another user's history. The view carries the same authentication seam as :class:~django_ag_ui.DjangoAGUIView (require_authenticated / get_user, sync or async); defaults stay open for parity with the catalog views, so lock it down whenever the agent endpoint is locked down.

Reference store (opt-in)

The django_pydantic_agent.contrib.store app ships a ready-to-use durable store. Add it to INSTALLED_APPS, run migrate, then set conversation_store= to django_pydantic_agent.contrib.store.default_conversation_store.DefaultConversationStore. Projects that don't opt in get no model and no migration.

DefaultConversationStore

Bases: ModelConversationStore

A ready-to-use model-backed store over :class:StoredConversation.

The batteries-included durable store: cross-device, per-user history with a cheap thread list. Enable it by adding "django_pydantic_agent.contrib.store" to INSTALLED_APPS, running migrate, and passing an instance to your transport's conversation_store= argument. For a bespoke schema, subclass :class:ModelConversationStore instead.

Owner scoping: every query filters by the owner_id the ModelConversationStore base resolves — the authenticated user's pk, or a per-browser anon:<session_key> bucket when the store is built with allow_anonymous=True (otherwise anonymous requests are refused rather than sharing one "" bucket). The unique (owner_id, thread_id) constraint holds regardless. Titles are derived from the first user message at first save and then left alone except by :meth:_rename; preview re-derives on every save.

StoredConversation

Bases: Model

The reference durable conversation row for a model-backed store.

One row per (owner_id, thread_id). messages holds the AG-UI message list as JSON (the same shape every store round-trips); title and preview are denormalised so the thread drawer's list query never loads message bodies, and updated_at orders it. owner_id is the resolved owner (the user's pk, or an anon:<session_key> bucket under ALLOW_ANONYMOUS) — the store always filters by it, the security boundary.

Used by :class:~django_pydantic_agent.contrib.store.default_conversation_store.DefaultConversationStore. Run lineage (run_id / step ledger) is intentionally out of scope here; a future durability layer can extend the schema.

File uploads

AttachmentStore

Bases: Protocol

Pluggable server-side storage for files a user attaches to a conversation.

Passed to AGUIServer(attachment_store=...). The package ships a no-op default (:class:~django_pydantic_agent.NullAttachmentStore — uploads off) and an abstract model-backed base (:class:~django_pydantic_agent.ModelAttachmentStore); the opt-in django_pydantic_agent.contrib.store app provides a ready :class:~django_pydantic_agent.contrib.store.default_attachment_store.DefaultAttachmentStore that keeps bytes in Django Storage (so S3 etc. come free via STORAGES/DEFAULT_FILE_STORAGE) and metadata in a row.

Every method is async and owner-scoped: a store filters by the acting user so one user can never read or delete another's files — the security boundary for the whole feature. save validates nothing about size/type itself (the view does, from its config); it just persists the bytes and returns a durable :class:AttachmentRef. open returns None for a missing or cross-owner id rather than raising, so callers map it to a 404.

Unlike conversations there is no scoped wrapper for attachments, and none is needed: they are id-referenced with no enumeration, and already owner-scoped, so two endpoints sharing a store expose nothing across the user boundary. Thread lists are the case that leaks.

AttachmentRef dataclass

A durable, lightweight reference to one uploaded file.

What an upload returns and what travels on the wire — never the bytes. A user drops a file into the composer, it is uploaded out-of-band to the :class:~django_pydantic_agent.persistence.attachments_view.AttachmentsView, and the server hands back this ref; the client holds it on the message and the agent reads the actual bytes server-side via the read_attachment tool. Keeping the AG-UI message stream free of base64 mirrors how the tool catalog keeps schemas off the wire.

id is the opaque, owner-scoped handle the store resolves back to bytes; mime is the declared content type (client-supplied, so treat it as a hint); size is the byte count; url is an optional direct fetch URL (e.g. the owner-checked download endpoint) and stays None unless a store fills it in.

OpenedAttachment dataclass

An attachment's metadata paired with a readable byte stream.

Returned by :meth:AttachmentStore.open <django_pydantic_agent.persistence.types.attachment_store.AttachmentStore> so the download view and the read_attachment tool both get the content and the :class:AttachmentRef (name / mime / size) in a single owner-scoped call.

content is an open, readable binary stream (a file handle), not the whole bytes — so a large attachment streams out via FileResponse rather than being buffered in memory. The consumer owns it: the download view hands it to FileResponse (which closes it) and the tool reads it under a with block. Read it exactly once.

NullAttachmentStore

The default attachment store: uploads disabled, server stays stateless.

save is never reached — the A transport's attachments view detects this store and returns 410 Gone so a misconfigured client gets a clear "uploads are off" signal instead of a silent success. open returns None (every fetch is a 404) and delete is a no-op, so the endpoint is inert until a real store is configured. save still raises if called directly, to fail loudly rather than fabricate a ref.

ModelAttachmentStore

Bases: ABC

Abstract base for a model-backed (or any sync) AttachmentStore.

Provides the async wrapping and per-request owner scoping — the same shape as :class:~django_pydantic_agent.ModelConversationStore — so a subclass implements three synchronous operations against its own storage (a Django Storage for the bytes, a model row for the metadata). Kept model-agnostic on purpose: the package ships no concrete model so it forces no migration; the opt-in django_pydantic_agent.contrib.store app supplies one.

Each _save / _open / _remove receives the resolved owner_id (None for anonymous) and must filter by it so files never cross users.

Example::

class MyStore(ModelAttachmentStore):
    def _save(self, upload, owner_id):
        row = MyAttachment.objects.create(owner_id=owner_id or "", ...)
        row.file.save(row.attachment_id, upload, save=True)
        return AttachmentRef(id=row.attachment_id, name=..., mime=..., size=...)
    def _open(self, attachment_id, owner_id): ...
    def _remove(self, attachment_id, owner_id): ...

AttachmentsView

Owner-scoped file-upload + download endpoint (async, multipart/JSON).

Mounted by :func:~django_ag_ui.get_urls with attachments=<store> over an :class:~django_pydantic_agent.persistence.types.attachment_store.AttachmentStore:

  • POST <prefix>attachments/ → multipart upload under the file field; validates size/type from DJANGO_AG_UI settings, persists the bytes, and returns 201 with the :class:AttachmentRef JSON ({"id", "name", "mime", "size", "url"?}) — a durable ref, not bytes.
  • GET <prefix>attachments/<id>/ → stream the bytes back (owner-checked) for preview/download; missing or cross-owner → 404.
  • DELETE <prefix>attachments/<id>/ → drop the attachment (204).

Every operation is scoped to the acting user: the store filters by owner, so one user's id can never resolve another's file. Downloads are served as an attachment with X-Content-Type-Options: nosniff so an uploaded text/html can't execute as a same-origin page. The view carries the same authentication seam as :class:~django_ag_ui.DjangoAGUIView (require_authenticated / get_user); defaults stay open for parity with the catalog views, so lock it down whenever the agent endpoint is.

With the default :class:NullAttachmentStore an upload returns 410 (off): mount the view with a real store to enable it.

Reference attachment store (opt-in)

The same django_pydantic_agent.contrib.store app ships a ready-to-use durable file store. With the app installed and migrated, set attachment_store= to django_pydantic_agent.contrib.store.default_attachment_store.DefaultAttachmentStore. The bytes go to Django Storage (S3/GCS via STORAGES / DEFAULT_FILE_STORAGE); projects that don't opt in get no model and no migration.

DefaultAttachmentStore

Bases: ModelAttachmentStore

A ready-to-use model-backed store over :class:StoredAttachment.

The batteries-included durable file store: bytes live in Django Storage (filesystem by default, S3/GCS via STORAGES), metadata in a row. Enable it by adding "django_pydantic_agent.contrib.store" to INSTALLED_APPS, running migrate, and passing an instance to your transport's attachment_store= argument. For a bespoke schema, subclass :class:ModelAttachmentStore.

Owner scoping: every query filters by the owner_id the ModelAttachmentStore base resolves — the authenticated user's pk, or a per-browser anon:<session_key> bucket when the store is built with allow_anonymous=True (otherwise anonymous requests are refused rather than sharing one "" bucket) — so one user's id never resolves another's file. The unique (owner_id, attachment_id) constraint holds regardless. The public attachment_id is an opaque UUID, kept separate from the storage filename.

StoredAttachment

Bases: Model

The reference durable attachment row for a model-backed store.

The opaque attachment_id is the handle the wire ref carries; file holds the bytes via Django Storage (so S3 etc. come free through STORAGES/DEFAULT_FILE_STORAGE), while name / mime / size are the denormalised metadata returned without reading the file back. owner_id is the resolved owner (the user's pk, or an anon:<session_key> bucket under ALLOW_ANONYMOUS) — the store always filters by it, the security boundary. thread_id optionally ties an attachment to one conversation; it is left blank when a file is uploaded before a thread exists, so attachments never depend on a conversation row.

Used by :class:~django_pydantic_agent.contrib.store.default_attachment_store.DefaultAttachmentStore.

Voice input

TranscriptionBackend

Bases: Protocol

Pluggable server-side speech-to-text for the composer's voice input.

Resolved from DJANGO_AG_UI["TRANSCRIPTION_BACKEND"]. The package ships a no-op default (:class:~django_ag_ui.NullTranscriptionBackend — voice off) and an opt-in reference implementation over an OpenAI-compatible /audio/transcriptions endpoint (:class:~django_ag_ui.contrib.transcription.openai_transcription_backend.OpenAITranscriptionBackend).

The single method is async and receives the acting request so a backend can scope by user / rate-limit / bill per principal. Unlike :class:~django_pydantic_agent.persistence.types.attachment_store.AttachmentStore, transcription keeps no durable artifact: the recorded audio is transcribed and the text returned in one shot (the composer drops it into the textarea), so there is nothing to open or delete. transcribe validates nothing about size/type itself (the view does, from settings); it just turns audio bytes into text.

NullTranscriptionBackend

The default transcription backend: voice input disabled.

transcribe is never reached through the endpoint — the :class:~django_ag_ui.persistence.transcribe_view.TranscribeView detects this backend and returns 410 Gone so a misconfigured client gets a clear "voice is off" signal instead of a silent failure. transcribe still raises if called directly, to fail loudly rather than fabricate a transcript. The endpoint is inert until a real backend is configured via DJANGO_AG_UI["TRANSCRIPTION_BACKEND"].

TranscribeView

Owner-scoped speech-to-text endpoint (async, multipart in / JSON out).

Mounted by :func:~django_ag_ui.get_urls with transcribe=<backend> over a :class:~django_ag_ui.persistence.types.transcription_backend.TranscriptionBackend:

  • POST <prefix>transcribe/ → multipart audio under the audio field; validates size/type from DJANGO_AG_UI settings, runs the backend, and returns 200 with {"text": "<transcript>"}.

The audio is transcribed and discarded — nothing is stored — so unlike :class:~django_ag_ui.persistence.attachments_view.AttachmentsView there is no download/delete route. The view carries the same authentication seam as :class:~django_ag_ui.DjangoAGUIView (require_authenticated / get_user); defaults stay open for parity with the other endpoints, so lock it down whenever the agent endpoint is.

With the default :class:NullTranscriptionBackend a request returns 410 (off): mount the view with a real backend to enable it.

Reference transcription backend (opt-in)

A ready-to-use backend over any OpenAI-compatible /audio/transcriptions endpoint. Install the [openai] extra and set transcription_backend= to django_ag_ui.contrib.transcription.openai_transcription_backend.OpenAITranscriptionBackend; subclass it to change the model or point at another OpenAI-compatible server.

OpenAITranscriptionBackend

A ready-to-use transcription backend over an OpenAI-compatible API.

The batteries-included voice backend: it forwards the recorded clip to an OpenAI /audio/transcriptions endpoint and returns the text. Enable it by installing the [openai] extra and pointing DJANGO_AG_UI["TRANSCRIPTION_BACKEND"] at this class's dotted path (django_ag_ui.contrib.transcription.openai_transcription_backend.OpenAITranscriptionBackend).

Self-configuring so :func:~django_ag_ui.resolve_transcription_backend can instantiate it with no arguments: the API key comes from the OPENAI_API_KEY environment variable (the SDK default). Override the model or point at an OpenAI-compatible server (Azure OpenAI, a local Whisper server, Groq, …) by subclassing and setting the class attributes::

class GroqTranscription(OpenAITranscriptionBackend):
    model = "whisper-large-v3"
    base_url = "https://api.groq.com/openai/v1"

The openai SDK is imported lazily inside :meth:transcribe so the base package keeps it an optional dependency (the [openai] extra).

Internal helpers

These are not part of the public re-export surface but are referenced from the guides.

build_model

build_model(model: str, *, api_key: str | None = None, provider: Any = None) -> Any

Build a Pydantic-AI model from a "provider:name" string + explicit key.

Delegates the provider: prefix → Model-class resolution to Pydantic-AI's own :func:infer_model, supplying a provider_factory that injects the credentials instead of letting Pydantic-AI read them from the environment:

  • provider (a Provider instance) takes precedence — used as-is, so it can carry a custom base_url / client.
  • otherwise api_key is passed to the prefix's default Provider class (resolved via :func:infer_provider_class).

Because the prefix map lives in Pydantic-AI, every provider it knows works automatically (anthropic, openai, openai-responses, google, groq, bedrock, …) — there is no hand-maintained table to drift out of date.

A bare model name Pydantic-AI can map to a provider (e.g. claude-… → anthropic) is accepted too; only when the provider can't be resolved at all is an error raised.

Raises:

Type Description
ImproperlyConfigured

when the model's provider can't be resolved — an unknown / uninferable prefix, or the matching provider extra not installed. Pass provider= a Provider instance for anything Pydantic-AI can't infer.

build_tool_catalog

build_tool_catalog(
    registry: ToolRegistry,
    *,
    drf_mcp_server: Any = None,
    service_specs: dict[str, Any] | None = None,
) -> list[dict[str, Any]]

The agent's server-tool catalog for the frontend to label tool-call cards.

Server-side tools (the @tool registry, and drf-mcp tools when a drf_mcp_server is passed) execute server-side, so their JSON Schema never reaches the browser — the web component can't read an x-summary off it. This catalog is the channel for those labels: the component fetches it via data-tools-url and maps tool name → label.

Each entry is {"name", "summary", "description"?}. summary is always present, resolved from the single source of truth with a fallback chain:

  • registry tools → @tool(summary=…) → a prettified name;
  • drf-mcp tools → display_nametitle → a prettified name.

description (a longer blurb for tooltips) is included when available (ToolSpec.description / drf-mcp display_descriptiondescription). Registry tools win on name collisions.

DRFMCPToolset

Bases: AbstractToolset[Any]

Exposes a drf-mcp MCPServer's tools as a Pydantic-AI toolset.

Built per request so the acting user is the current AG-UI user. Tool schemas and execution both route through drf-mcp's public in-process surface (MCPServer.list_tools / acall_tool), so the advertised parameters, serializer validation, and permissions match the HTTP transport exactly. Tool definitions carry the default kind="function" — the in-process kind the run loop routes into call_tool, which then emits a TOOL_CALL_RESULT and lets the model continue (an external tool would instead be deferred to the client and never run).

exclude_names carries the @tool registry's names: on a collision the registry tool wins (the same rule build_tool_catalog applies) and the drf-mcp twin is skipped — otherwise pydantic-ai raises UserError for the duplicate name at run time.

max_retries is each tool's retry budget: how many times a :class:pydantic_ai.ModelRetry (malformed arguments, a service-raised validation error) is fed back to the model before the run aborts. Defaults to 1, matching pydantic-ai's own function-tool default.

get_tools async

get_tools(ctx: Any) -> dict[str, ToolsetTool[Any]]

Load tool defs from drf-mcp's tools/list once, then wrap them.

Loading runs in a thread (sync_to_async) because the sync list_tools may evaluate per-user listing permissions against the DB, which Django forbids on the async event loop.

build_attachment_toolset

build_attachment_toolset(
    store: AttachmentStore, request: HttpRequest
) -> FunctionToolset[None]

Build a per-request toolset exposing read_attachment over store.

Mirrors the per-request drf-mcp bridge: the request is captured in a closure so store.open is owner-scoped to the acting user — the model can only read files that user uploaded, never another's by id. Wired in by a transport's agent build when an attachment store is configured, so the wire stays protocol-vanilla: attachments travel as lightweight refs and the bytes are reached here, server-side, only when the model asks.