Skip to content

Django

Available · pip install agent-adapter django · add 'agent-adapter[server]' to serve the HTTP tool server beside Django

Async views await the service. Sync views call run_sync. Both work under ASGI (uvicorn, daphne) and WSGI (gunicorn); Django runs async views under WSGI on a loop of its own.

File Does
examples/backend_common.py the shared service layer (see FastAPI)
examples/backend_django.py a one-file project: settings, two async views, one sync view, URLs, an ASGI app with the tool server
tests/docs/test_backend_examples.py runs it offline with Django's Client, AsyncClient and Starlette's TestClient

Data enters as request.body (JSON), goes through invoke / decide / ask to the executor, and leaves as a JsonResponse with the reply's status.

One Executor per process

BACKEND = build_backend()  # module import: one per worker process

Build it at module import (in a real project, an apps.py or a services.py module). Django imports it once per worker process. Never build one per request: run guards, the ledger and the budget hook live on it.

Async and sync views

@post_only
async def call_tool(request: HttpRequest, name: str) -> JsonResponse:
    """An async view: await the service on Django's event loop."""
    read = _read(request)
    if isinstance(read, JsonResponse):
        return read
    _, body = read
    return _json(await invoke(BACKEND, name, body.get("arguments", {})))


@post_only
def decide_call(request: HttpRequest, approval_id: str) -> JsonResponse:
    """A sync view (WSGI): run_sync runs the coroutine on a fresh loop."""
    read = _read(request)
    if isinstance(read, JsonResponse):
        return read
    user, body = read
    work = decide(
        BACKEND,
        approval_id,
        reviewer=user,
        approved=body.get("approved") is True,
        edited_args=body.get("edited_arguments"),
    )
    return _json(run_sync(work, timeout=TIMEOUT_S))


@post_only
async def ask_model(request: HttpRequest) -> JsonResponse:
    read = _read(request)
    if isinstance(read, JsonResponse):
        return read
    _, body = read
    return _json(await ask(BACKEND, str(body.get("question", ""))))


urlpatterns = [
    path("tools/<str:name>", call_tool),
    path("approvals/<str:approval_id>/decide", decide_call),
    path("ask", ask_model),
]
View How it calls the service Under ASGI Under WSGI
async def await invoke(...) on the server's loop Django runs it with async_to_sync (a loop per request)
def run_sync(decide(...), timeout=30) Django runs it in a thread (sync_to_async); run_sync starts a fresh loop there in the request thread; run_sync starts a fresh loop

asgiref.sync.async_to_sync(invoke)(...) also works in a sync view, but it does not close LoopLocal values when its loop ends (they are only dropped later). run_sync closes them.

Approvals in a request/response cycle

sequenceDiagram
  participant C as Client
  participant V as async view call_tool
  participant D as sync view decide_call
  participant E as Executor
  participant St as ApprovalStore
  C->>V: POST /tools/send_invoice
  V->>E: await invoke(...)
  E->>St: pending record
  V-->>C: 202 approval_id
  participant R as Reviewer
  R->>D: POST /approvals/{id}/decide
  D->>St: run_sync(decide(...)): reviewer checked, decision stored
  D->>E: same run id, call id, arguments
  E->>St: consume (once)
  D-->>R: 200 result
Rule Why
keep the store outside the process: SQLiteApprovalStore (one host), RedisApprovalStore, SQLAlchemyApprovalStore on your database (Database stores), or an ApprovalStore over a Django model workers and restarts must see the same records
create and consume must be atomic (a unique constraint, select_for_update, or an UPDATE ... WHERE consumed = false) each decision is used exactly once; find returns only the unconsumed record
decide only as a reviewer the invoker should not approve itself
never set ToolContext(approval=...) from request data it skips the approval port

Budget per request

Each view call is one run, so BudgetHook(max_usd) caps one request (ask answers 429 over it). For a per-user cap, pass thread_id=str(request.user.pk) to run_scope and use BudgetHook(max_usd, scope="thread").

The HTTP tool server beside Django

def asgi_application() -> Any:
    """Django and the REST tool server in one ASGI app (asgi.py; needs agent-adapter[server])."""
    from django.core.asgi import get_asgi_application
    from starlette.applications import Starlette
    from starlette.routing import Mount

    from agent_adapter.server import create_app

    tools = create_app(registry, mcp=False, **tool_server_options(BACKEND))
    django_app: Any = get_asgi_application()  # an ASGI app; typed loosely by django-stubs
    return Starlette(routes=[Mount("/agent", app=tools), Mount("/", app=django_app)])

Put this in asgi.py. Starlette routes /agent/... to the tool server and everything else to Django. Under WSGI use create_wsgi_app(registry, **options), a path dispatcher in wsgi.py and atexit.register(tools.close) (as in Flask); events (SSE) then need ASGI.

Logging and tracing

Add the agent_adapter logger to LOGGING["loggers"]; LoggingHook() writes one line per tool call there (arguments redacted). Executor events go to sink=LoggingSink() or sink=OTelSink().

Pitfalls

Pitfall Fix
two host checks Django's ALLOWED_HOSTS guards Django; the tool server's allowed_hosts= guards /agent (default: local names)
CSRF on the JSON API token-authenticated JSON views need no session, so no CSRF; with session auth keep CsrfViewMiddleware
ORM calls inside an async tool wrap them in sync_to_async, or make the tool sync (the executor runs sync tools in a thread)
run_sync inside an async def view await instead; run_sync would block the loop
MemoryApprovalStore with several workers SQLiteApprovalStore, RedisApprovalStore, SQLAlchemyApprovalStore, DynamoDBApprovalStore, or your own ApprovalStore
Idempotency-Key on the tool server with several workers idempotency=RedisIdempotencyStore(...)
a loop-bound client used by tools LoopLocal (each WSGI request and each run_sync call is a new loop)

See also: FastAPI, Flask, Celery, Runtime, HTTP server.