AI agents by framework / Python
How to add an AI agent to a Django app
An AI agent in a Django app is an async view that calls a model with tools, runs the tool calls the model makes against your own models and services as request.user, and streams the reply to the page. The OpenAI Agents SDK runs the model loop and pauses for approval; Django supplies authentication, CSRF protection and storage for paused runs. If the agent only needs to do what your pages already do, you can embed one instead.
What an agent in a Django app is made of
- A view: an async view behind
@login_requiredand Django's CSRF middleware that calls the model. The model API key comes from the server environment and never reaches a template. - Tools: functions the model may call, each wrapping a query or service your app already has. Synchronous ORM code runs through
sync_to_asyncfrom an async view. - The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of turns.
- Streaming to the page: a
StreamingHttpResponsethat sends server-sent events, and a template with a small script that renders them. - Authorization and approval: tools act as
request.user, and a paused run waiting for the user's approval is stored in your database, owned by that user.
Ways to add an agent to Django
| Approach | Good for | What you take on |
|---|---|---|
| OpenAI Agents SDK or Pydantic AI in an async view | A custom agent with your own tools, model provider and UI. | Tool definitions, authorization in every tool, storage for paused runs, approval UI, rate and cost limits. |
| An agent framework (for example LangGraph) | Long-running or multi-agent workflows with checkpoints and orchestration. | Another layer to learn and deploy, plus everything in the row above. |
| Hosted answer-only chat widget | Questions about your docs or help center. | Keeping its content current. It does not act in your app. |
| Embedded in-app agent (Bunkhouse) | Letting signed-in users get tasks done through the pages and API you already have. | A script tag, an identity endpoint and hints for operations discovery cannot see. |
Build an agent with the OpenAI Agents SDK
The OpenAI Agents SDK is OpenAI's Python library for agents: function tools, the run loop, streaming and human-in-the-loop approval. The examples below use version 0.24 with Django 6.1 async views. Install it in your project's environment:
pip install openai-agentsThe SDK reads the API key from the OPENAI_API_KEY environment variable and calls OpenAI's Responses API. The example sets gpt-5.6-sol explicitly; without a model the SDK uses gpt-5.6-luna. Other providers work through its LiteLLM and any-llm adapters, described under Models.
1. The agent and its tools
The @tool decorator turns a function into a tool, with the input schema taken from its type hints and the description from its docstring. The first parameter, a RunContextWrapper, is not shown to the model: it carries the context the view passes in, which holds the signed-in user's ID. The model can choose an order ID but never whose orders it reads or cancels. The setting needs_approval=True pauses the run before cancel_order executes. The tools are async, so synchronous ORM code goes through sync_to_async rather than blocking the event loop.
from dataclasses import dataclass
from typing import Literal
from agents import Agent, RunContextWrapper
from agents.decorators import tool
from asgiref.sync import sync_to_async
from . import services # your existing data layer (synchronous ORM code)
@dataclass
class UserContext:
user_id: int
@tool
async def list_orders(
ctx: RunContextWrapper[UserContext], status: Literal["open", "shipped", "cancelled"] | None = None
) -> list[dict]:
"""List the signed-in user's recent orders."""
return await sync_to_async(services.list_orders)(ctx.context.user_id, status=status)
@tool(needs_approval=True)
async def cancel_order(ctx: RunContextWrapper[UserContext], order_id: str) -> dict:
"""Cancel one of the signed-in user's open orders."""
return await sync_to_async(services.cancel_order)(ctx.context.user_id, order_id)
order_agent = Agent[UserContext](
name="Orders assistant",
model="gpt-5.6-sol",
instructions="You help the signed-in user manage their orders. Use the tools; never guess order data.",
tools=[list_orders, cancel_order],
)2. Streaming views and approval
Runner.run_streamed returns at once, and stream_events() yields events while the model and tools run. The async generator turns text deltas into server-sent events, which StreamingHttpResponse sends as they are produced. The argument max_turns=5 caps model calls per request; the SDK raises MaxTurnsExceeded when a run needs more.
When the model calls cancel_order, the stream ends and result.interruptions lists the pending call. The view saves the serialized run in a PendingApproval row owned by the user and sends the browser only its ID and the call to display. The SDK's human-in-the-loop guide asks for exactly this: RunState.from_json does not authenticate a snapshot, so it must come from storage you control, never from the client. Do not use request.session for it either: the session middleware saves the session before a streaming body runs, so writes made in the generator are lost.
The approve view looks the row up by ID and owner, deletes it before resuming so a replayed request cannot run the cancellation twice, restores the run with the current user as its context, and applies the decision. The resumed run streams back through the same generator.
from django.conf import settings
from django.db import models
class PendingApproval(models.Model):
"""A paused agent run, kept on the server until its owner approves or rejects it."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
state = models.JSONField()
created = models.DateTimeField(auto_now_add=True)import json
from agents import MaxTurnsExceeded, Runner, RunState
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, StreamingHttpResponse
from django.shortcuts import aget_object_or_404
from django.views.decorators.http import require_POST
from openai.types.responses import ResponseTextDeltaEvent
from .agent import UserContext, order_agent
from .models import PendingApproval
MAX_TURNS = 5
def sse(data: dict) -> str:
return f"data: {json.dumps(data)}\n\n"
async def events(result, user):
try:
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
yield sse({"type": "text", "delta": event.data.delta})
except MaxTurnsExceeded:
yield sse({"type": "text", "delta": " (Stopped: too many steps.)"})
if result.interruptions:
# Keep the paused run on the server; the browser only gets an ID and what to show.
pending = await PendingApproval.objects.acreate(user=user, state=result.to_state().to_json())
calls = [{"tool": item.name, "arguments": item.arguments} for item in result.interruptions]
yield sse({"type": "approval", "id": pending.pk, "calls": calls})
def stream(result, user) -> StreamingHttpResponse:
return StreamingHttpResponse(
events(result, user),
content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@login_required
@require_POST
async def chat(request):
user = await request.auser()
result = Runner.run_streamed(
order_agent, request.POST["message"], context=UserContext(user.pk), max_turns=MAX_TURNS
)
return stream(result, user)
@login_required
@require_POST
async def approve(request, pk: int):
user = await request.auser()
pending = await aget_object_or_404(PendingApproval, pk=pk, user=user)
deleted, _ = await PendingApproval.objects.filter(pk=pending.pk).adelete()
if not deleted: # another request already resumed this run
return HttpResponse(status=409)
state = await RunState.from_json(order_agent, pending.state, context_override=UserContext(user.pk))
for item in state.get_interruptions():
if request.POST.get("decision") == "approve":
state.approve(item)
else:
state.reject(item)
return stream(Runner.run_streamed(order_agent, state, max_turns=MAX_TURNS), user)from django.urls import path
from . import views
urlpatterns = [
path("agent/chat/", views.chat, name="agent-chat"),
path("agent/approvals/<int:pk>/", views.approve, name="agent-approve"),
]3. The template
Browsers' EventSource can only send GET requests, so the page posts with fetch and reads the event stream from the response body. Both requests carry the form's csrfmiddlewaretoken, so Django's CSRF middleware checks them like any other form post. Tool names and arguments come from the model and are rendered with textContent, never as HTML.
<form id="agent-form" method="post" action="{% url 'agent-chat' %}">
{% csrf_token %}
<input name="message" aria-label="Message" autocomplete="off" required>
<button>Send</button>
</form>
<div id="agent-output" aria-live="polite"></div>
<script>
const form = document.getElementById("agent-form");
const output = document.getElementById("agent-output");
async function run(url, body) {
body.set("csrfmiddlewaretoken", form.elements.csrfmiddlewaretoken.value);
const response = await fetch(url, { method: "POST", body });
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
const reply = output.appendChild(document.createElement("p"));
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop();
for (const event of events) {
const data = JSON.parse(event.slice("data: ".length));
if (data.type === "text") reply.textContent += data.delta;
if (data.type === "approval") askApproval(data);
}
}
}
function askApproval({ id, calls }) {
const box = output.appendChild(document.createElement("div"));
// Tool arguments come from the model: render them as text, never as HTML.
box.textContent = calls.map((call) => call.tool + " " + call.arguments).join(", ");
for (const decision of ["approve", "reject"]) {
const button = box.appendChild(document.createElement("button"));
button.textContent = decision === "approve" ? "Approve" : "Reject";
button.onclick = () => {
box.remove();
const body = new FormData();
body.set("decision", decision);
run("/agent/approvals/" + id + "/", body);
};
}
}
form.addEventListener("submit", (event) => {
event.preventDefault();
run(form.action, new FormData(form));
form.reset();
});
<\/script>This example answers one message per request. To carry a conversation across requests, pass a session from the SDK's sessions support, keyed by the signed-in user.
Production checklist for Django
- Serve over ASGI. Under WSGI, Django has to consume an async iterator completely before sending it, so nothing streams and each request holds a worker. Run an ASGI server such as Uvicorn or Daphne, as in Django's ASGI deployment guide, and turn off response buffering in any proxy in front of it (the view's
X-Accel-Buffering: noheader does this for nginx). - Keep CSRF and authentication on both views. Never mark the agent views with
@csrf_exempt, and keep@login_requiredon them. Scope every query in every tool to the user from the context, not to an ID the model supplies. - Expire paused runs. A
PendingApprovalrow holds the conversation and tool arguments. Delete rows that were never answered after a short period, and treat the stored state as personal data. - Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with your own business rules, and require approval for anything irreversible.
- Limit cost. Cap turns, rate-limit the views per user (Django has no built-in rate limiter; use your cache or proxy) and set spending limits with your model provider.
- Keep secrets and traces in mind. Read
OPENAI_API_KEYfrom the environment, not from committed settings. The SDK's tracing is on by default and sends run traces to OpenAI; set the variableOPENAI_AGENTS_DISABLE_TRACING=1if that does not fit your data policy.
Option: embed an in-app agent with Bunkhouse
If the agent's job is to help signed-in users do work your Django app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.
The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Django backend keeps enforcing its own authorization on every request.
In Django, include the loader once in the base template's <head>, before your own scripts, together with two small static hooks: agent-identity.js fetches the identity token, and the hook agent-auth-headers.js sends Django's X-CSRFToken header with the assistant's writes. For signed-in users, add a GET /api/agent-identity view that signs a short-lived token for the user in request.user. It answers 401 itself, so do not wrap it in @login_required, and the signing secret stays in a server environment variable.
{% load static %}
<head>
<!-- ... -->
<script src="{% static 'agent-identity.js' %}"><\/script>
<script src="{% static 'agent-auth-headers.js' %}"><\/script>
<script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
<!-- your own scripts come after the loader -->
</head>The tested Django integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.
When Bunkhouse fits, and when to build it yourself
- Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
- Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
- Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
- Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.
Try it on your Django app first
A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Django app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Django page at http://127.0.0.1:8000.Use a page with disposable data: everything the assistant reads is sent to your agent.
The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.
Related guides
- How to add AI to your website: chat assistants, in-app agents and custom builds compared
- MCP vs WebMCP: choosing where outside agents act on your site
- AI agents in other Python frameworks: FastAPI, Flask