Agents
An agent is the core of the Django AI SDK. It’s a plain Python class that describes how your AI behaves, what it can do, and how it streams responses.
This guide covers everything about defining agents. See the Protocols guide for how Stream and Run work, and Views and Routing for wiring agents to HTTP endpoints.
Anatomy of an Agent
from django_ai_sdk import Agent
from django_ai_sdk.adapters.base import Run, Stream
from django_ai_sdk.agents import auto_register
from django_ai_sdk.common import prompt
from django_ai_sdk.protocols.vercel import VercelProtocolHandler
from django_ai_sdk.storage.db import DbStorageAdapter
@auto_register
class MyAgent(Agent):
name = "My Bot"
description = "A helpful assistant"
model = "openai/gpt-oss-120b"
instructions = prompt("You are a helpful agent.")
protocol = VercelProtocolHandler
storage_adapter = DbStorageAdapter
max_history = 20
tools = [get_today, get_memory_files]
async def get_pipeline_adapter(self, thread_id=None, user=None):
# Build the Stream that powers streaming chat.
...
async def get_run_adapter(self, thread_id=None, user=None):
# Build the Run that powers non-streaming calls.
...Registration
Every Agent subclass registers itself in the registry when the module is imported. Use AI_SDK_AGENTS in settings to ensure your modules are loaded:
# settings.py
AI_SDK_AGENTS = [
"your_app.agents.MyAgent",
"your_app.agents.OtherAgent",
]Each agent gets a stable UUID derived from its module and class name, so it never changes between restarts. Resolve agents by ID with AgentService:
from django_ai_sdk.agents.services import AgentService
agent = await AgentService.get(agent_id)Mark a class abstract = True to use it as a shared base without registering it, or hidden = True to keep it out of agent listings (useful for worker-only agents).
Class Configuration
These class attributes configure an agent’s behavior:
| Attribute | Default | Purpose |
|---|---|---|
name |
None | Display name |
description |
None |
Short description, surfaced in agent listings |
model |
None |
Model identifier passed to the generator |
llm |
openai_responses_chat |
Generator factory from django_ai_sdk.generators, assigned uncalled |
llm_kwargs |
None |
Vendor generation parameters, e.g. {"reasoning": {"effort": "low"}} |
instructions |
"You are a helpful agent." |
System prompt, built with prompt() |
protocol |
VercelProtocolHandler |
Protocol handler for message conversion |
storage_adapter |
MemoryStorageAdapter |
Storage used when a thread isn’t found elsewhere |
tools |
[] |
Class-level tool providers |
agents |
[] |
Delegate-able subagent agent classes (see Multi-Agent Swarms) |
max_agent_steps |
6 |
Loop limit when this agent runs as a subagent (build_subagent) |
max_tool_calls |
6 |
Tool-call cap as a subagent; None disables it |
integrations |
[] |
Integration names whose tools reach this agent |
artifacts |
[] |
ArtifactSchema subclasses exposed as submission tools |
rag_provider |
None |
RAGProvider instance to enable RAG |
rag_document_limit |
10000 |
Intended cap on documents fetched for RAG indexing (not yet enforced) |
memories |
[] |
Default connected memories |
permissions |
None |
Permission classes gating agent operations; None or [] uses the agent domain default |
max_history |
None |
Cap on messages sent to the model |
title_generation |
True |
Auto-generate thread titles |
response_format |
None |
Pydantic model for structured run() output |
file_upload |
False |
Enable file upload for threads |
file_pipelines |
[] |
Per-file-type processing pipelines |
citation_formatter_class |
DefaultCitationFormatter |
How RAG sources are rendered to the model |
suggestion_generator |
None |
Generates follow-up suggestions after a reply |
hidden |
False |
Hide from registry listings |
abstract |
False |
Shared base, not registered |
warmup_on_init |
False |
Warm up RAG on agent initialization |
The prompt() helper
Use prompt() to build instructions. It wraps your text in a Prompt and strips leading indentation, so multi-line instruction blocks stay clean:
instructions = prompt("""\
You are a helpful AI agent who always responds like a pirate.
Use pirate language, expressions, and mannerisms in all your responses.
- Be creative with pirate slang but keep responses helpful and informative.
""")Adapters: Stream and Run
An agent exposes two adapter hooks. You implement them; the SDK uses them.
get_pipeline_adapter(thread_id, user): returns aStreamfor streaming chat.Agent.as_view()calls this.get_run_adapter(thread_id, user): returns aRunfor non-streaming calls.Agent.run()calls this (title generation, structured extraction, background jobs).
hidden = True, never used in chat) can leave get_pipeline_adapter() unimplemented.See the Protocols guide for the full Stream and Run API.
Tools
Tools let the model call your Python functions. The SDK expects Haystack Tool objects: the model’s tool schema is derived from them.
Class-level tools
Agent.get_tools() runs every callable in the class-level tools list, passing context kwargs. Each provider may return one tool or a list:
# your_app/tools.py
from haystack.tools import Tool
def get_datetime() -> dict:
"""Return the current date and time."""
...
def get_today(**kwargs) -> Tool:
"""Current date and time tool."""
return Tool(
name="get_today",
parameters={},
description="Get current date and time",
function=get_datetime,
)# your_app/agents.py
class MyAgent(Agent):
...
tools = [get_today, get_memory_files]Providers receive context about the current request:
def get_memory_files(thread_id="", user_id="", **kwargs) -> Tool:
"""List files attached to the current thread."""Multi-Agent Swarms
A single agent can delegate sub-tasks to a team of subagent agents. Any Agent subclass can declare a crew with the agents class attribute:
class PirateBoatExpertAgent(Agent):
"""Expert on pirate boats and seafaring lore."""
name = "Pirate Boat Expert"
model = settings.AI_SDK_DEFAULT_MODEL
hidden = True # worker-only: never listed in the registry
tools = [pirate_boat_expert_tool]
instructions = prompt("You are the crew's pirate boat expert. ...")
class TreasureHunterAgent(Agent):
name = "Treasure Hunter"
model = settings.AI_SDK_DEFAULT_MODEL
hidden = True
tools = [find_treasure_tool]
instructions = prompt("You are the crew's treasure hunter. ...")
class PirateSwarmAgent(Agent):
name = "Pirate Agent Swarm"
model = settings.AI_SDK_DEFAULT_MODEL
agents = [PirateBoatExpertAgent, TreasureHunterAgent] # the crew
instructions = prompt("You are a triage agent for a crew of pirate subagents. ...")When the coordinator builds its tools, each subagent becomes a native Haystack ComponentTool the coordinator can call to delegate a sub-task:
- The subagent runs its own agent loop — its own generator, tools, and system prompt — and its final message is returned to the coordinator as the tool output. Subagents are ephemeral and stateless; the coordinator’s
Streamremains the single source of streamed text, but the subagent’s tool calls stream too (see Observability), so sub-agent activity is visible in the UI while the report text stays single-sourced. - The tool schema is a single
taskparameter; the coordinator is instructed to delegate with a self-contained task description. - Subagent tool names are derived from the subagent display name (slugified), deduped with a numeric suffix on collision.
- Subagents the user lacks
VIEW_AGENTpermission on are skipped automatically. - Cyclic crews (A delegates to B, B back to A) are detected at build time and the back-edge is skipped.
- The coordinator’s
get_system_prompt()automatically appends anAvailable subagents:roster.
Subagents are ordinary Agent subclasses with hidden = True — they keep the name/description/model/tools/instructions attributes, so they only need a tools list and an instructions prompt:
class MySubagent(Agent):
name = "My Subagent"
description = "What it does"
model = settings.AI_SDK_DEFAULT_MODEL
hidden = True
tools = [my_tool]
instructions = prompt("You are a subagent. Complete delegated tasks concisely.")Guardrails live on each Agent itself: subagents cap their own loop via max_agent_steps and max_tool_calls (via ToolCallBudgetHook), both defaulting to 6 — max_tool_calls = None disables the cap. The coordinator’s ToolAgentConfig (max_agent_steps, max_tool_calls) caps its own turn the same way. See the demo demos/studio/apps/agents/agent_swarm.py (pirate crew) and demos/studio/apps/agents/deep_research.py (web-research worker using duckduckgo-api-haystack + trafilatura).
Observability
LogToolCallsHook (a before_tool hook) logs every tool call an agent makes, so you can confirm delegation from the server console even when the frontend doesn’t render tool events:
config = ToolAgentConfig(..., hooks={"before_tool": [LogToolCallsHook()]})The two swarm demos wire it into both the coordinator and (via build_subagent) each subagent, so you’ll see lines like Armed subagent tool 'treasure_hunter' ..., Tool call: treasure_hunter args={...} (coordinator delegating), then Tool call: find_treasure args={...} (the subagent running its own tool). Streaming clients receive tool_call_start / tool_input / tool_output SSE events for the coordinator’s own calls and for the subagent’s calls: the coordinator forwards its streaming callback into subagent tools (stream_subagent_tools, on by default), and SubagentStreamFilter passes only tool-related chunks through — the subagent’s raw text tokens are dropped so the report isn’t streamed twice. Those forwarded chunks are persisted into the conversation history as they happen — in the order the calls actually start, so the stored history matches the streamed one and subagent tool activity survives a page reload.
Subagent loop limits are per-subagent class attributes, not just coordinator settings: max_agent_steps caps total loop iterations and max_tool_calls (via ToolCallBudgetHook) hard-caps tool calls, both defaulting to 6 on the subagent’s own Agent class. The coordinator’s ToolAgentConfig (max_agent_steps, max_tool_calls) caps its own turn the same way. See the demo demos/studio/apps/agents/agent_swarm.py (pirate crew) and demos/studio/apps/agents/deep_research.py (web-research worker using duckduckgo-api-haystack + trafilatura).
Overriding get_tools()
For full control, override get_tools() on your agent. The default implementation combines:
- Class-level
toolsproviders - Integration tools (
integrationslist, resolved via the integrations registry) - Artifact submission tools (
artifactslist) - Subagent agent tools (
agentslist, oneComponentToolper subagent)
class MyAgent(Agent):
async def get_tools(self, thread_id="", user=None):
tools = await super().get_tools(thread_id=thread_id, user=user)
tools.append(await self.get_custom_tool(thread_id))
return toolsTools for uploaded files, and required tools
Two hooks, two questions:
get_attachment_tools(thread_id) |
get_run_required_tools(messages) |
|
|---|---|---|
| Answers | Which tools can the model use? | Which of those must it have called before it answers this turn? |
| Returns | Tool objects, added to the toolset by get_tools() |
Tool names |
| Decided | Per thread: a file uploaded three turns ago is still reachable | Per turn, from this turn’s messages |
| Default | ask_image, when the agent takes uploads and a vision agent is set |
[] |
required_tools (a class attribute) lists names that are required on every run; get_run_required_tools() adds names for one turn.
A required tool is not run for you: the model supplies the arguments, so the model calls it. When it tries to answer without having called a required tool, it is told to call it now and gets another turn; that happens at most twice per run. A required name the run has no tool for is ignored, so a tool you require must also be added (tools = [...] or get_attachment_tools()).
For example, always read an attached PDF with your own tool:
class MyAgent(Agent):
required_tools = ["get_today"]
def get_attachment_tools(self, thread_id):
# Keep ask_image, add a PDF reader for this thread's files.
return [*super().get_attachment_tools(thread_id), read_pdf_tool(thread_id)]
def get_run_required_tools(self, messages):
last = next((m for m in reversed(messages) if m.role == "user"), None)
if last and any(a.media_type == "application/pdf" for a in last.attachments):
return ["read_pdf"]
return []To give the model a file’s content without any tool call, override format_attachment() instead: its text goes straight into the context line of the attachment.
Integration tools
Declare integrations = ["linear", "weather"] and every tool that integration exposes reaches the model. Tools are namespaced (linear_list_issues) so unrelated integrations never collide. Unauthorized users’ tools never reach the model. See the Integrations guide.
Artifact tools
Agents can submit structured results as artifacts. Each ArtifactSchema subclass in the artifacts list becomes a submission tool the model can call during a turn:
class MyArtifact(ArtifactSchema):
kind = "report"
title: str
content: strclass MyAgent(Agent):
artifacts = [MyArtifact]Artifacts after a tool
tool_artifacts shows an artifact every time a tool runs, without the model calling the artifact tool. It is keyed by tool name, so it also works for tools you didn’t write (package or hidden tools):
from django_ai_sdk.artifacts import FileArtifact, ToolArtifact
from django_ai_sdk.memories.models import EntryDocument
from django_ai_sdk.memories.tools import get_thread_file
async def looked_up_file(arguments, result, thread_id):
# Artifact data from the tool call, or None for no artifact this time.
doc = await EntryDocument.objects.filter(
id=arguments["document_id"], memory__thread_files__id=thread_id
).afirst()
return {"files": [get_thread_file(doc, thread_id)]} if doc else None
class MyAgent(Agent):
tool_artifacts = {"lookup_file": ToolArtifact(FileArtifact, looked_up_file)}When the stream sees the tool’s result, the builder gets the tool’s arguments and its result (a returned list or dict comes back as one). The artifact is then stored and streamed right after it as its own artifact_* tool part: the frontend renders it with the component it already has, and it is saved with the message. The model only gets the tool’s own result. Data the artifact rejects is logged and skipped. Only streamed chats show it: in Agent.run() the tool runs as usual. It works for any tool of the run: your own, package, MCP or RAG tools, also ones your get_pipeline_adapter adds after get_tools().
Retrieval-Augmented Generation (RAG)
RAG connects documents stored in memories to your agent. The RAGProvider caches pipelines per agent and memory and exposes a per-memory retrieval tool for the model to call.
Enabling RAG
- Set
rag_provider = RAGProvider()on the agent. - Override
get_rag_queryset()to return the documents to index. - Implement
get_rag_pipeline()(or one of the named variants) to return a RAG pipeline. - Link memories to threads:
get_rag_tools()builds one tool per active memory.
from django_ai_sdk.memories.models import Entry
from django_ai_sdk.rags import (
BM25QueryExpanderRAG,
BM25QueryExpanderRAGConfig,
QdrantBM25HybridRAG,
QdrantBM25HybridRAGConfig,
)
from django_ai_sdk.rags.provider import RAGProvider
class MyAgent(Agent):
rag_provider = RAGProvider()
async def get_rag_queryset(self, memory_id=None):
if memory_id:
return Entry.objects.filter(memory_id=memory_id)
return Entry.objects.all()
async def get_rag_pipeline(self, memory_id=None):
documents = await self.get_rag_documents(memory_id)
if not documents:
return None
return BM25QueryExpanderRAG(
documents=documents,
config=BM25QueryExpanderRAGConfig(top_k=5, n_expansions=4),
)Pipeline variants
| Variant | Retrieval |
|---|---|
BM25QueryExpanderRAG |
BM25 keyword retrieval with query expansion |
ChromaDBQueryExpanderRAG |
Dense embedding retrieval with ChromaDB |
QdrantBM25HybridRAG |
Hybrid SPLADE + BGE embeddings with RRF, stored in Qdrant |
Override get_rag_pipeline() to return one of the variants. You can factor each into its own method (e.g. get_rag_pipeline_qdrant()) and call it from get_rag_pipeline(); the base method returns None (RAG disabled).
Lifecycle
- Warm up:
Agent.warmup(agent, memory_id)pre-builds and caches pipelines. - Reindex:
Agent.reindex(agent, memory_id, force_rebuild)rebuilds indexes (persistent backends like Qdrant delete and recreate). - Clear cache:
Agent.clear_rag_cache(agent)drops cached pipelines. - CLI:
python manage.py warmup_rag --agent <name>warms up from the command line (see the CLI guide).
Citations
Retrieved sources are streamed back as numbered citations. get_citation_registry() returns a fresh per-turn registry, and citation_formatter_class controls how sources are rendered for the model. The DefaultCitationFormatter numbers sources so the model can reference them inline.
Storage
Conversations live in threads of messages. Two storage adapters ship with the SDK:
MemoryStorageAdapter: in-memory, useful for tests and prototypesDbStorageAdapter: persists via the Django ORM
MemoryStorageAdapter is in-memory only: use DbStorageAdapter whenever conversations must survive a restart.get_storage_adapter(thread_id) locates which adapter actually holds a thread (querying all registered adapters) and returns the right instance, falling back to the agent’s configured storage_adapter.
class MyAgent(Agent):
storage_adapter = DbStorageAdapterstorage_adapter is the attribute you configure (the same name is also used as the effective instance attribute in __init__). Agent additionally declares a storage class annotation for the same purpose: either way, the effective adapter defaults to MemoryStorageAdapter and comes from storage_adapter at runtime.
Structured Output
Agent.run() returns a Pydantic model when a response_format is set:
from pydantic import BaseModel
class Movie(BaseModel):
title: str
year: int
class MyAgent(Agent):
response_format = Movie
async def get_run_adapter(self, thread_id=None, user=None):
return Run(generator=self.get_llm())
# agent.run(messages) -> Movie | strPass response_format=None explicitly to run() to disable structured output for a single call.
run() also takes tools (default False): pass tools=True and the agent’s own
get_tools() are resolved and run to completion headless - no streaming, one call in,
one final answer out. Opt-in, not opt-out: resolving tools reaches every configured
integration, so a one-shot call (title generation, structured extraction, a workflow
step) does not pay that cost unless it explicitly asks for it. Setting a
response_format skips tools regardless of the flag - structured output and a tool
loop together aren’t supported yet, see
Structured output from a tool loop.
This only ever applies to the SDK’s own Run adapter - a get_run_adapter() override
that returns something else is left alone, rather than a tool loop being forced onto
it. See Headless tool runs for the exact
call chain and the diagram: it’s governed by this agent’s own max_agent_steps and
max_tool_calls, via the same assembly a streamed subagent delegation uses - never a
generic default.
Permissions
Each agent declares permissions: classes that gate operations like CHAT, VIEW_THREAD, and USE_INTEGRATION. The demo shows per-domain overrides:
# settings.py
AI_SDK_PERMISSIONS = {
"memory": ["apps.memories.permissions.AllowAnonymousMemoryPermission"],
"thread": ["apps.agents.permissions.DemoThreadPermission"],
}Agent.as_view() and Agent.history() check permissions before doing anything, raising PermissionDenied when the user lacks access. See the Views and Routing guide for wiring.
Files
Set file_upload = True to accept file uploads on threads; leaving it False rejects uploads to the agent’s threads with a PermissionDenied. Each FilePipeline pairs a processor (e.g. TextFileProcessor) with optional transforms, including LLM extraction agents, that run over the uploaded file:
class MyAgent(Agent):
file_upload = True
file_pipelines = [
FilePipeline(
TextFileProcessor(),
transforms=[DocumentExtractionTransform(ExtractionAgent())],
),
]See the Files reference for the shipped processors (Text, CSV, JSON, Docx, Pptx, Xlsx), transforms, the upload/processing lifecycle, and upload settings (AI_SDK_MAX_UPLOAD_SIZE, AI_SDK_ALLOWED_FILES, AI_SDK_MEMORY_FILE_PIPELINE).
Suggestions
Set suggestion_generator to stream follow-up questions after the assistant’s reply:
from django_ai_sdk.adapters.suggestions import DefaultSuggestionGenerator
class MyAgent(Agent):
suggestion_generator = DefaultSuggestionGeneratorReference Agents
The demo project ships seven agents in demos/studio/apps/agents/, each showcasing a different pattern:
| Agent | Pattern |
|---|---|
PirateBasicAgent |
Full-featured agent: RAG, tools, integrations, citations, suggestions, files |
PirateSwarmAgent |
Coordinator + hidden pirate subagents (agents = [...]) |
DeepResearchAgent |
Coordinator + hidden web-research subagent |
DefaultRuntimeAgent |
Base for DB-configured runtime agents |
WorkspaceAgent |
Artifact-based workspace workflows |
PirateExtractionAgent |
Structured extraction used inside file pipelines |
ResearchPlannerAgent |
Hidden web-research worker (search + fetch tools) |
Browse them in the repository for working examples of everything in this guide.