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
|
require_authenticated
|
bool
|
Answer |
True
|
authorize
|
Callable[[HttpRequest], bool] | None
|
Per-request gate answering |
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 |
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: |
DEFAULT_URL_NAMESPACE
|
staff_required ¶
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 a fresh registry with the default server-side admin tools.
register_admin_tools ¶
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 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 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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"].
auto_confirm
instance-attribute
¶
When True, destructive UI tools run without a confirmation modal.
Passed to the Web Component as autoConfirm.
tool_display
instance-attribute
¶
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
¶
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
¶
Optional override for the skill catalog (client Skill dicts). None
uses the built-in admin catalog
(build_skills).
theme
instance-attribute
¶
Web Component theme: "light" / "dark" / "auto" / "code".
None leaves the component default (light).
density
instance-attribute
¶
"comfortable" / "compact". None leaves the default.
placement
instance-attribute
¶
"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
¶
Incoming-text animation: "none" / "fade" / "word". None
leaves the default (none).
strings
instance-attribute
¶
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
¶
URL of a header/launcher icon image, passed through as data-icon-url.
None leaves the sidebar icon-less.
side
instance-attribute
¶
For placement="sidebar": which edge it docks to — "left" /
"right" (data-side). None leaves the component default (right).
chat_surface_tools
instance-attribute
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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 ¶
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
|
DEFAULT_URL_NAMESPACE
|
user
|
Any
|
The signed-in principal, used only to scope the stored
conversation to them. Optional, and |
None
|
build_route_map ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Return the configured Django apps with labels, names, and model counts.
list_models ¶
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 ¶
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 ¶
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 ¶
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 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 ¶
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.