Skip to content

API reference

The public Python surface re-exported from django_admin_agent.

Package entry points

AdminAgentServer

Bases: AGUIServer

The admin sidebar's mount object — a django_ag_ui.AGUIServer pre-configured for the Django admin.

Construct it once and mount its namespaced urls the admin.site.urls way, alongside the admin:

from django.contrib import admin
from django.urls import path
from django_admin_agent import AdminAgentServer

urlpatterns = [
    path("admin/", admin.site.urls),
    path("admin-agent/", AdminAgentServer().urls),
]
# reverse("admin_agent:endpoint") · "admin_agent:tools" · "admin_agent:threads" · …

It mounts the agent endpoint and its tool catalog, plus the thread / attachment / transcription sub-views when their stores are passed (the same conditional mounting as AGUIServer).

Two gates, and they answer different questions. Every mounted route requires an authenticated, active staff user — without that an unauthenticated visitor could drive the agent and stream model data back over SSE. But is_staff is Django's flag for may enter the admin, not a permission over anything inside it, so it is only the door. Past it, every tool that reads a model consults the acting user's own admin permissions and the registered ModelAdmin.get_queryset(request), which is what keeps the agent exactly as capable as the person driving it: a staff user with no permission on a model gets nothing for it here, just as in the admin.

That second gate needs to know who is asking, which is what this class's deps_factory default (build_admin_deps) is for. It binds the acting request for the run and then defers to any deps_factory you pass, so supplying your own cannot switch the gate off.

Every keyword below this class's own passes straight through to django_ag_ui.AGUIServer via **kwargs — the model, the stores, the toolsets and capabilities, the drf-mcp bridge, the per-request model and instructions hooks, the throttle, and anything added there later. They are deliberately not enumerated here; the ones below are re-declared only because this package overrides their defaults, and a test asserts the rest keep flowing through.

Parameters:

Name Type Description Default
registry ToolRegistry | None

The server-side tool registry. None uses the built-in admin tools (build_default_registry).

None
require_authenticated bool

Answer 401 to an anonymous request.

True
authorize Callable[[HttpRequest], bool] | None

Per-request gate answering 403 when it returns False, as JSON rather than an HTML login redirect. The default admits active staff; pass lambda r: r.user.is_superuser to tighten.

staff_required
csrf_exempt bool

Drop CSRF protection from the agent endpoint. Left off — the sidebar bootstrap already sends the token.

False
deps_factory Callable[[HttpRequest], Any] | None

Per-run request -> AgentDeps hook. Re-declared only so the acting request is bound whether or not you pass one; yours still decides what the deps are.

None
namespace str

The URL namespace the mounted routes live under, which the sidebar reverses against. Pass the same value to the template tag when it is not the default:

{% django_admin_agent_sidebar namespace="internal-agent" %}
DEFAULT_URL_NAMESPACE

staff_required

staff_required(request: HttpRequest) -> bool

The default authorization gate: an active, staff user.

The mounted routes are JSON / SSE endpoints, so this returns a bool the views turn into a 403 — unlike admin_view(), whose HTML login redirect would corrupt an SSE stream or a JSON fetch.

build_default_registry

build_default_registry() -> ToolRegistry

Build a fresh registry with the default server-side admin tools.

register_admin_tools

register_admin_tools(registry: ToolRegistry) -> None

Register the full server-side admin tool set on registry.

Combines the read-only shell.* (ORM) and introspect.* (Django + admin introspection) tools.

register_shell_tools

register_shell_tools(registry: ToolRegistry) -> None

Register the read-only ORM tool set on registry.

"Shell" here means the ORM, not a shell. The four tools build and read QuerySets and nothing else — no eval, no exec, no subprocess, no arbitrary code of any kind — and the name follows ToolCategory.SHELL, the upstream vocabulary a tool card and the tool catalog display. The category is advisory metadata: it labels a tool, it does not grant it anything. What these four can actually reach is decided per call by admin_queryset, against the acting user's admin permissions.

This function itself only populates registry; it executes no tool.

register_introspect_tools

register_introspect_tools(registry: ToolRegistry) -> None

Register the read-only Django-introspection tool set on registry.

Permission parity

The tools answer as the staff user driving the sidebar. These are the pieces that establish who that is and what they may read — see Access control for the rules they enforce.

build_admin_deps

build_admin_deps(
    request: HttpRequest, factory: Callable[[HttpRequest], Any] | None = None
) -> Any

Bind the acting admin request for this run, then build its deps.

Mounted as the endpoint's deps_factory, which is the hook called once per request, with the live request, on the run's own context. Binding here is what lets a plain server-side tool — handed only the arguments the model chose, and nothing about who asked — answer as the staff user actually driving the sidebar.

factory is the project's own deps_factory when it passed one. It still decides what the deps are; the binding happens either way, so replacing the deps cannot quietly unbind the acting user and leave the model-reading tools with nobody to answer as.

bind_acting_request

bind_acting_request(request: HttpRequest) -> Iterator[None]

Run a block with request as the admin request the tools act for.

The mounted endpoint binds this for you on every run, so a project mounting AdminAgentServer never calls it. Reach for it when something other than the endpoint drives the tools — a management command, a test, a bespoke agent loop — because the model-reading tools answer as the request's user and refuse to answer at all without one.

The binding is reset on exit, so it cannot outlive the block and be read by a later call on the same thread:

with bind_acting_request(request):
    rows = query_model("shop", "Order", limit=10)

authorized_model_admin

authorized_model_admin(app_label: str, model: str) -> Any

The ModelAdmin the acting staff user may read this model through.

The single gate in front of every tool that reads a model's rows or its field-level shape. It refuses with PermissionDenied in exactly the cases the admin itself would show the user nothing: a model the project never registered, a model MODEL_SCOPE excludes, and a model whose ModelAdmin denies the acting user view permission.

The message names all three causes and commits to none of them, on purpose: which one held is the fact worth withholding, since "never registered" and "no view permission" separate a model that does not exist from one this user may not read.

Where the message ends up is the transport's call, not this function's, and it now takes a different route than it used to. From django-pydantic-agent 0.18 a PermissionDenied ends the run rather than being converted into a tool failure -- converting it was the problem, since a denied call came back to the model as a generic, retryable failure that spends no retry budget, which is an existence oracle a model can sweep ids with.

So it never travels the TOOL_FAILURE path, and TOOL_FAILURE["INCLUDE_DETAIL"] no longer governs it. It travels RUN_ERROR instead, which django-ag-ui 0.49 redacts under the same setting: with detail off the browser is told the run failed, and this message reaches the audit record and the operator's log only. The two floors have to move together -- on django-ag-ui 0.48 nothing redacted that path, so this text reached the transcript verbatim.

The ambiguity is the belt to that braces: even shown, the message does not separate a model that does not exist from one this user may not read.

Raises:

Type Description
LookupError

when the model is not installed at all.

PermissionDenied

when it is installed but not readable by this user.

admin_queryset

admin_queryset(app_label: str, model: str) -> QuerySet[Any]

The rows the acting staff user's own admin would show for a model.

Two things happen here, and the second is the one that is easy to skip: authorized_model_admin settles whether the user may read this model, and then the queryset comes from ModelAdmin.get_queryset(request) rather than the model's default manager. A project that scopes a changelist per tenant, per owner, or per region does it by overriding that method, so reading through it is what makes the agent see the same rows the changelist would and no others.

visible_model_admin

visible_model_admin(model_cls: type[Model]) -> Any | None

The ModelAdmin the acting user may see model_cls through, or None.

This is the whole authorization rule, in one place, and it is the admin's own: a model reaches the agent when the project registered it with the admin site, MODEL_SCOPE admits it, and the registered ModelAdmin grants the acting user view permission — which is has_perm over view_ / change_ plus whatever the project overrode. A model absent from the admin is one the staff user cannot open in the admin either, so the agent does not open it.

require_acting_request

require_acting_request() -> HttpRequest

The admin request this tool call is acting for.

Raises:

Type Description
PermissionDenied

when nothing is bound. Every model-reading tool answers as the acting staff user's own admin would, so without a request there is no user to answer as, and the only safe answer is none at all.

model_in_scope

model_in_scope(model_cls: type[Model]) -> bool

Whether MODEL_SCOPE lets the sidebar touch this model at all.

Unset (the default) means the admin registry is the only scope. A list narrows further, and can only ever narrow: an entry is an app_label or an app_label.ModelName, matched case-insensitively.

resolve_model

resolve_model(app_label: str, model: str) -> type[Model]

Look up a model class by app_label + model.

Resolution only. Whether the acting user may see that model is a separate question, answered by visible_model_admin.

Raises:

Type Description
LookupError

when the model is not installed; the message names both parts so the agent can self-correct.

Settings

AdminAgentSettings dataclass

Snapshot of the user-configurable DJANGO_ADMIN_AGENT settings.

Built fresh on every read so test overrides take effect immediately. The agent model itself is configured separately via django-ag-ui's DJANGO_AG_UI["MODEL"].

title instance-attribute

title: str

Header text shown on the sidebar chat panel.

auto_confirm instance-attribute

auto_confirm: bool

When True, destructive UI tools run without a confirmation modal. Passed to the Web Component as autoConfirm.

tool_display instance-attribute

tool_display: str

How much detail tool-call cards show: "minimal", "compact", or "full". Passed to the Web Component as the data-tool-display attribute; defaults to "compact" for a dense admin sidebar.

message_actions instance-attribute

message_actions: str

Which per-message actions the sidebar offers, as a comma-separated list of copy / retry / feedback. Passed through as data-message-actions.

Defaults to "copy,retry", which is also the component's own default from 0.31.0 -- this states it rather than subtracting from it. The rating buttons fire an ag-ui-feedback event and store nothing by design, because a rating belongs to whatever a project already uses for product signal, and nothing here listens for it.

The setting exists for the other direction. A project that wires its own listener on the sidebar element sets "copy,retry,feedback" and gets the thumbs back; without this it would have no way to ask. Setting it explicitly also means the admin's row does not move if the component's default does.

skills instance-attribute

skills: list[dict[str, Any]] | None

Optional override for the skill catalog (client Skill dicts). None uses the built-in admin catalog (build_skills).

theme instance-attribute

theme: str | None

Web Component theme: "light" / "dark" / "auto" / "code". None leaves the component default (light).

density instance-attribute

density: str | None

"comfortable" / "compact". None leaves the default.

placement instance-attribute

placement: str | None

"bottom-left" / "side" / "sidebar" / "full" / "embedded" (or unset for the default floating bottom-right). "sidebar" is a full-height docked panel that collapses to an icon rail; pair it with side.

text_animation instance-attribute

text_animation: str | None

Incoming-text animation: "none" / "fade" / "word". None leaves the default (none).

strings instance-attribute

strings: dict[str, Any] | None

Localized UI strings for the Web Component, passed through as its data-strings table (a partial override merged over the English defaults). Wrap values in gettext_lazy so the sidebar follows the admin's active language. None leaves the component's English defaults.

icon_url instance-attribute

icon_url: str | None

URL of a header/launcher icon image, passed through as data-icon-url. None leaves the sidebar icon-less.

side instance-attribute

side: str | None

For placement="sidebar": which edge it docks to — "left" / "right" (data-side). None leaves the component default (right).

chat_surface_tools instance-attribute

chat_surface_tools: bool

Whether the agent may move, minimise and restore its own panel.

On by default, which is a departure from how this package treats a new agent capability -- enableCharts(["tool"]) is left to the project because it widens what the agent can do. The difference is what the widened surface reaches: these four tools can move the sidebar and nothing else. They read no model, change no row and touch no data, so the only cost is four tool definitions in each request, and the thing they buy is the one problem a chat pinned to its own tab never has -- this one sits on top of the changelist it is being asked about.

False registers none of them. Worth turning off for a placement that owns its own position and has no collapsed state, since there the tools can only ever answer that they did nothing.

start_open instance-attribute

start_open: bool

Whether a corner placement opens on a first visit, rather than resting at its launcher.

False is the component's own behaviour from 0.35.0 and the default here: an admin page arrives with the chat as a bubble in the corner, not with a panel over the changelist somebody came to read. Before that the panel opened itself, so a site that wants the old behaviour back sets this to True.

Only the corner placements have a collapsed state to rest in, so this does nothing under "sidebar", "embedded" or "page". A choice the user makes by collapsing or expanding outlives this: it is the first visit this decides, and the stored preference wins afterwards.

launcher_drag instance-attribute

launcher_drag: bool

When False, the sidebar stays where your CSS puts it: the collapsed bubble cannot be dragged around the screen and the open panel cannot be moved by its header (data-launcher-drag="false").

Defaults to True, which is the component's own default. Reach for False when the sidebar's position is part of a designed admin layout rather than something each user should arrange -- and note that a docked placement already fixes the position on its own, so this is for the floating one.

theme_toggle instance-attribute

theme_toggle: bool

When True, show the Web Component's built-in light/dark header toggle (data-theme-toggle), which flips theme and persists per tab. Defaults to False — the admin's own theme usually governs.

shell_field_redaction instance-attribute

shell_field_redaction: bool | str

Sensitive-field redaction for the shell.* tools. True (default) redacts any field whose name matches the built-in denylist pattern before the row reaches the LLM, and refuses to filter or order on such a field — even legitimate staff use shouldn't stream auth.User password hashes to a third-party model. False disables both; a regex str replaces the built-in pattern with your own. This is data minimisation, not access control: what the acting user may read at all is decided by their admin permissions.

model_scope instance-attribute

model_scope: list[str] | None

Optional narrowing of which models the sidebar's tools may touch at all.

None (default) means the admin site's own registry is the scope: the tools reach a model when it is registered with the admin and the acting user has view permission for it. A list narrows that further and can only ever narrow — an entry is an "app_label" or an "app_label.ModelName", matched case-insensitively:

DJANGO_ADMIN_AGENT = {"MODEL_SCOPE": ["shop", "auth.User"]}

Reach for it when the admin is broad but the sidebar should not be.

get_settings

get_settings() -> AdminAgentSettings

Read the active DJANGO_ADMIN_AGENT settings dict.

Admin wiring

build_sidebar_context

build_sidebar_context(
    namespace: str = DEFAULT_URL_NAMESPACE, *, user: Any = None
) -> dict[str, Any]

Build the context the sidebar template needs.

Reverses the AG-UI endpoint URL, resolves the bootstrap module's static URL, reads the title / auto-confirm flag from settings, and resolves the admin index URL so the frontend nav.* tools can build changelist / changeform URLs without reversing named routes in the browser. Shared by the {% django_admin_agent_sidebar %} template tag and the SidebarAdminSite each_context hook.

Parameters:

Name Type Description Default
namespace str

The mounted AdminAgentServer to reverse against — the one it was constructed with. An argument rather than a setting, because a project may mount more than one sidebar.

DEFAULT_URL_NAMESPACE
user Any

The signed-in principal, used only to scope the stored conversation to them. Optional, and None reproduces the previous behaviour exactly.

None

build_route_map

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

Build the agent's navigable-route manifest from the admin registry.

One changelist route (and one add route, when available) per registered model, shaped for the Web Component's routeMap{id, path, title, group}. The agent calls list_routes to discover destinations and navigate_to_route to jump to one, instead of guessing admin URLs.

build_skills

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

The built-in admin skill catalog (client Skill dicts).

SidebarAdminSite

Bases: AdminSite

A drop-in AdminSite that exposes the sidebar config to every page.

Adds django_admin_agent (the sidebar context) to each_context so a base template can render the chat without the template tag. Using the {% django_admin_agent_sidebar %} tag in admin/base_site.html is the more common path and does not require swapping the admin site.

sidebar_namespace names the mounted AdminAgentServer to reverse against, matching the namespace= it was built with — the class attribute mirrors the tag's argument, so a project running two admin sites can point each at its own server:

class InternalAdminSite(SidebarAdminSite):
    sidebar_namespace = "internal-agent"

each_context

each_context(request: HttpRequest) -> dict[str, Any]

Add the sidebar context, and an empty one for a signed-out visitor.

each_context runs for the admin's login page too, so this key is built for visitors who are not signed in. The template tag renders nothing for them; this path cannot, because what gets rendered is the host's own markup and that decision is genuinely theirs.

What it can do is stop handing them anything to render. An anonymous request gets {} rather than a populated context, which matters for a reason beyond the dead launcher: build_route_map() takes no user and walks admin.site._registry unfiltered, so a populated context carries an inventory of every registered model, its label and its admin URL. A host that renders unconditionally was publishing that on its login page.

Two states rather than the template tag's three: AdminSite.each_context reads request.user before this override runs, so "no user to judge" raises upstream and cannot be reached here.

{} rather than omitting the key, deliberately. It is falsy, so {% if django_admin_agent %} works as the natural guard and a host that already wrote one is unaffected. And {{ django_admin_agent.endpoint }} resolves to the empty string either way, so a host that renders unconditionally is no worse off than before -- their launcher was already inert for an anonymous visitor, since the endpoint refuses anyone who is not active staff. They lose only the manifest they should not have had.

Server-side tools

shell.*

query_model

query_model(
    app_label: str,
    model: str,
    filter: dict[str, Any] | None = None,
    exclude: dict[str, Any] | None = None,
    order_by: list[str] | None = None,
    select_related: list[str] | None = None,
    prefetch_related: list[str] | None = None,
    fields: list[str] | None = None,
    limit: int = 50,
    offset: int = 0,
) -> list[dict[str, Any]]

Query a Django model and return matching rows as JSON-safe dicts.

Reads exactly what the acting staff user's own admin changelist would: the model has to be registered with the admin site, in MODEL_SCOPE, and one this user has view permission for, and the rows come from that ModelAdmin.get_queryset(request) rather than the model's default manager.

filter and exclude accept ORM lookup kwargs (e.g. {"email__icontains": "@foo"}), but not on a redacted field. fields projects via .values(); if omitted, every concrete field is returned. limit is hard-capped at 1000 to keep responses bounded. Sensitive fields (name matching the SHELL_FIELD_REDACTION denylist) are redacted.

get_model_instance

get_model_instance(
    app_label: str,
    model: str,
    pk: Any,
    select_related: list[str] | None = None,
    fields: list[str] | None = None,
) -> dict[str, Any] | None

Fetch a single row by primary key. Returns None when not found.

Looks only in the rows the acting staff user's own admin would show, so a row that user's ModelAdmin.get_queryset(request) filters out reads as "not found" here too. Sensitive fields (name matching the SHELL_FIELD_REDACTION denylist) are redacted before the row is returned.

count_model

count_model(
    app_label: str,
    model: str,
    filter: dict[str, Any] | None = None,
    exclude: dict[str, Any] | None = None,
) -> int

Return the row count for a model, optionally filtered.

Counts the same rows the acting staff user's own admin changelist would show, and refuses a filter that reads a redacted field — a bare count is still an answer about the value it filtered on.

inspect_model_schema

inspect_model_schema(app_label: str, model: str) -> dict[str, Any]

Return a JSON-safe description of a model's schema.

Includes concrete fields with types, nullability, relations, indexes, db_table, and Meta ordering — a good first step before writing queries. Described only for a model the acting staff user could open in the admin: a field list is a map of where the data is, and handing one out for a model the user may not read describes a table they cannot see.

redact_sensitive_fields

redact_sensitive_fields(row: dict[str, Any]) -> dict[str, Any]

Replace values of sensitive-named fields with a redaction marker.

This is data minimisation, not authorization. What the acting user may read at all is settled before a row exists, by the admin's own permissions and ModelAdmin.get_queryset (admin_queryset). This pass then keeps values the user is entitled to see from being shipped to a third-party model when there is no good reason to: an auth.User password hash is readable by anyone with change permission on users, and the sidebar still should not stream it.

It matches the field's name against redaction_pattern, which makes it a heuristic and incomplete by construction. A secret in a column named pw, or inside a JSON blob named profile, is not matched; neither is a field declared name="pw" over db_column="password". Widen it with your own regex via SHELL_FIELD_REDACTION, and treat "the sidebar cannot reach this model at all" — leaving it out of the admin, or out of MODEL_SCOPE — as the control for anything that must never leave the database.

Applied to every row the shell tools emit. The tools that emit no rows close the same gap from the other side: reject_redacted_lookups stops a matched field being read back one bit at a time through a filter.

reject_redacted_lookups

reject_redacted_lookups(
    filter: dict[str, Any] | None = None,
    exclude: dict[str, Any] | None = None,
    order_by: list[str] | None = None,
) -> None

Refuse an ORM lookup that reads a field redaction is meant to hide.

Masking a value on the way out only closes the direct route. A free-form filter re-opens it: password__startswith="pbkdf2_sha256$1" answers yes or no, and because a CharField also supports __gt / __lt, the answers binary-search a hash out roughly six calls per character. The count or the row list is the oracle; nothing needs to print the value. Ordering by such a field leaks the same information more slowly.

So every lookup path a caller controls is checked against the same pattern the output path uses, segment by segment, which also covers a relation walk like owner__password__gt. A match is refused with an explanation rather than silently dropped, so the agent can say what happened and move on.

Raises:

Type Description
ValueError

naming the offending lookup.

redaction_pattern

redaction_pattern() -> re.Pattern[str] | None

The active field-name redaction pattern, or None when disabled.

Governed by DJANGO_ADMIN_AGENT["SHELL_FIELD_REDACTION"]: True (default) uses the built-in denylist, False turns redaction off, and a regex str replaces the denylist with your own.

introspect.*

list_installed_apps

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

Return the configured Django apps with labels, names, and model counts.

list_models

list_models(app_label: str | None = None) -> list[dict[str, Any]]

List the Django models the acting staff user can work with here.

That is the admin's own answer, not the app registry's: a model the user has no view permission for, or one the sidebar is not scoped to, is left out, exactly as the admin index leaves it out. Each entry carries the app label, model name, DB table, and Meta-derived flags. Use inspect_model_schema for the full field-level shape.

list_urls

list_urls(prefix: str | None = None) -> list[dict[str, Any]]

Walk the root URL configuration and return every registered route.

Each entry includes the rendered pattern, view identifier, and URL name. prefix filters by string-containment against the pattern.

list_signals

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

Enumerate Django's built-in signals and their connected receivers.

Returns one row per signal with the count and identifier of each connected receiver. Custom third-party signals are not enumerated — there is no central registry for them.

get_settings_summary

get_settings_summary() -> dict[str, Any]

Return a curated, JSON-safe subset of Django settings.

Sensitive keys (SECRET_KEY, DB passwords, raw OPTIONS) are excluded or redacted, so the result is safe to surface to an agent.

list_admin_models

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

List the admin-registered models the acting staff user may view.

The same list the admin index would show that user, so the agent never offers to open a changelist that would answer 403. Each entry carries the model's admin metadata (list_display, list_filter, search_fields) and the reverse-resolved admin URLs (changelist + add) so the agent can navigate without guessing URL shapes. Works for both vanilla ModelAdmin and subclasses (Unfold) because every attribute is read defensively.

inspect_modeladmin

inspect_modeladmin(app_label: str, model: str) -> dict[str, Any]

Describe the ModelAdmin registered for a model.

Reads options via getattr so it transparently surfaces both standard Django options and the additive attributes that subclasses (Unfold) add. Refuses a model the acting staff user could not open in the admin — including one that is not registered there at all.