AI & Development · May 9, 2026 · 16 min read

How to Build a Production-Ready AI Agent API with FastAPI

You've built an AI agent that works in a Jupyter notebook. But when you try to put it into production — expose it as an API, handle concurrent users, manage rate limiting — everything falls apart. Here's the exact architecture and code we use at ZOO.

The Problem with Most AI Agent APIs

Most tutorials stop at:

@app.post("/chat")
def chat(message: str):
    response = agent.run(message)
    return {"response": response}

This works for a demo. In production, you'll hit these problems immediately:

  • No streaming — Users stare at a blank screen for 10+ seconds waiting for the full response
  • No session management — Every request is stateless; the agent forgets everything between calls
  • No rate limiting — One user can spam your API and drain your API credits
  • No error handling — When the LLM times out, your whole API crashes
  • No observability — You have no idea what's happening, what's failing, or what's costing money
  • No tool sandboxing — Your agent can execute arbitrary code with no guardrails

Architecture Overview

┌─────────────┐     ┌──────────────┐     ┌─────────────────┐
│   Client     │────▶│  FastAPI     │────▶│  Agent Engine   │
│  (HTTP/SSE)  │◀────│  Gateway     │◀────│  (Orchestrator) │
└─────────────┘     └──────┬───────┘     └────────┬────────┘
                           │                       │
                    ┌──────▼───────┐     ┌────────▼────────┐
                    │  Rate Limiter │     │  Tool Registry  │
                    │  Session Mgr  │     │  (Sandboxed)    │
                    └──────────────┘     └─────────────────┘
                           │
                    ┌──────▼───────┐
                    │  Observability│
                    │  (Logs/Metrics)│
                    └──────────────┘

Step 1: Project Structure

ai-agent-api/
├── app/
│   ├── main.py              # FastAPI entry point
│   ├── config.py            # Settings & env vars
│   ├── models/
│   │   ├── requests.py      # Request schemas
│   │   └── responses.py     # Response schemas
│   ├── agents/
│   │   ├── engine.py        # Core agent logic
│   │   ├── tools.py         # Tool definitions
│   │   └── memory.py        # Session memory
│   ├── middleware/
│   │   ├── rate_limit.py    # Rate limiting
│   │   └── auth.py          # API key auth
│   └── observability/
│       └── logger.py        # Structured logging
├── tests/
│   ├── test_api.py
│   └── test_agent.py
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example

Step 2: Configuration with Pydantic Settings

# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    # LLM
    openai_api_key: str = ""
    anthropic_api_key: str = ""
    default_model: str = "gpt-4o-mini"

    # API
    api_keys: str = ""  # Comma-separated valid API keys
    rate_limit_per_minute: int = 20

    # Sessions
    session_ttl_seconds: int = 3600  # 1 hour
    max_sessions: int = 1000

    # Observability
    log_level: str = "INFO"
    enable_metrics: bool = True

    class Config:
        env_file = ".env"

@lru_cache()
def get_settings() -> Settings:
    return Settings()

Step 3: Session Memory Manager

# app/agents/memory.py
import time
import uuid
from collections import OrderedDict
from threading import Lock
from app.models.requests import ChatMessage

class SessionManager:
    """Thread-safe session store with TTL-based eviction."""

    def __init__(self, max_sessions: int = 1000, ttl_seconds: int = 3600):
        self._sessions: OrderedDict[str, dict] = OrderedDict()
        self._max_sessions = max_sessions
        self._ttl = ttl_seconds
        self._lock = Lock()

    def get_or_create(self, session_id=None):
        with self._lock:
            if session_id is None or session_id not in self._sessions:
                sid = session_id or str(uuid.uuid4())
                self._sessions[sid] = {
                    "messages": [],
                    "created_at": time.time(),
                    "last_accessed": time.time(),
                }
                self._evict_if_needed()
                return sid, []

            session = self._sessions[session_id]
            session["last_accessed"] = time.time()
            self._sessions.move_to_end(session_id)
            messages = [ChatMessage(**m) for m in session["messages"]]
            return session_id, messages

    def add_message(self, session_id: str, message: ChatMessage):
        with self._lock:
            if session_id in self._sessions:
                self._sessions[session_id]["messages"].append(
                    message.model_dump()
                )
                self._sessions[session_id]["last_accessed"] = time.time()

    def _evict_if_needed(self):
        now = time.time()
        expired = [
            sid for sid, s in self._sessions.items()
            if now - s["last_accessed"] > self._ttl
        ]
        for sid in expired:
            del self._sessions[sid]
        while len(self._sessions) > self._max_sessions:
            self._sessions.popitem(last=False)

Step 4: Tool Registry with Sandboxing

# app/agents/tools.py
import subprocess
import json
from pydantic import BaseModel

class ToolDefinition(BaseModel):
    name: str
    description: str
    parameters: dict
    handler: callable
    requires_approval: bool = False

class ToolRegistry:
    """Sandboxed tool execution with timeout and output limits."""

    def __init__(self):
        self._tools: dict[str, ToolDefinition] = {}

    def register(self, definition: ToolDefinition):
        self._tools[definition.name] = definition

    def get_openai_schema(self) -> list[dict]:
        return [
            {
                "type": "function",
                "function": {
                    "name": t.name,
                    "description": t.description,
                    "parameters": t.parameters,
                }
            }
            for t in self._tools.values()
        ]

    async def execute(self, tool_name: str, arguments: dict) -> str:
        if tool_name not in self._tools:
            return json.dumps({"error": f"Unknown tool: {tool_name}"})

        tool = self._tools[tool_name]
        try:
            result = await tool.handler(**arguments)
            if len(str(result)) > 5000:
                result = str(result)[:5000] + "... [truncated]"
            return str(result)
        except Exception as e:
            return json.dumps({"error": str(e)})

async def execute_python(code: str) -> str:
    """Execute Python code in a sandboxed environment."""
    try:
        result = subprocess.run(
            ["python3", "-c", code],
            capture_output=True, text=True, timeout=10,
            env={"PATH": "/usr/bin"},
        )
        if result.returncode != 0:
            return f"Error: {result.stderr[:500]}"
        return result.stdout[:2000]
    except subprocess.TimeoutExpired:
        return "Error: Code execution timed out (10s limit)"

Step 5: The Agent Engine (Streaming + Tool Calling)

# app/agents/engine.py
import json
import time
from openai import AsyncOpenAI
from app.config import get_settings
from app.agents.memory import SessionManager
from app.agents.tools import create_default_registry

class AgentEngine:
    def __init__(self):
        self.settings = get_settings()
        self.client = AsyncOpenAI(api_key=self.settings.openai_api_key)
        self.sessions = SessionManager(
            max_sessions=self.settings.max_sessions,
            ttl_seconds=self.settings.session_ttl_seconds,
        )
        self.tools = create_default_registry()

    async def chat(self, message, session_id=None, model=None):
        model = model or self.settings.default_model
        sid, history = self.sessions.get_or_create(session_id)

        messages = self._build_messages(history, message)

        # First LLM call with tool support
        response = await self.client.chat.completions.create(
            model=model, messages=messages,
            tools=self.tools.get_openai_schema(),
            tool_choice="auto", stream=True,
        )

        tool_calls = []
        assistant_content = ""

        async for chunk in response:
            delta = chunk.choices[0].delta
            if delta.content:
                assistant_content += delta.content
                yield self._sse_chunk({
                    "type": "content", "content": delta.content,
                    "session_id": sid,
                })
            if delta.tool_calls:
                for tc in delta.tool_calls:
                    if tc.index >= len(tool_calls):
                        tool_calls.append({
                            "id": tc.id,
                            "name": tc.function.name if tc.function else "",
                            "arguments": tc.function.arguments if tc.function else "",
                        })
                    else:
                        if tc.function and tc.function.arguments:
                            tool_calls[tc.index]["arguments"] += tc.function.arguments

        # Execute tool calls
        if tool_calls:
            for tc in tool_calls:
                args = json.loads(tc["arguments"]) if tc["arguments"] else {}
                result = await self.tools.execute(tc["name"], args)
                yield self._sse_chunk({
                    "type": "tool_result", "tool": tc["name"],
                    "result": result[:200], "session_id": sid,
                })

            # Final response after tools
            final = await self.client.chat.completions.create(
                model=model, messages=messages, stream=True,
            )
            async for chunk in final:
                delta = chunk.choices[0].delta
                if delta.content:
                    yield self._sse_chunk({
                        "type": "content", "content": delta.content,
                        "session_id": sid,
                    })

        yield self._sse_chunk({
            "type": "done", "session_id": sid,
            "tokens_used": 0, "tools_called": [tc["name"] for tc in tool_calls],
        })

    def _sse_chunk(self, data):
        return f"data: {json.dumps(data)}\n\n"

Step 6: Rate Limiting Middleware

# app/middleware/rate_limit.py
import time
from collections import defaultdict
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware

class RateLimitMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, requests_per_minute: int = 20):
        super().__init__(app)
        self.rpm = requests_per_minute
        self._requests: dict[str, list[float]] = defaultdict(list)

    async def dispatch(self, request: Request, call_next):
        if request.url.path in ("/health", "/docs", "/openapi.json"):
            return await call_next(request)

        api_key = request.headers.get("X-API-Key", "anonymous")
        now = time.time()
        self._requests[api_key] = [
            t for t in self._requests[api_key] if now - t < 60
        ]

        if len(self._requests[api_key]) >= self.rpm:
            raise HTTPException(
                status_code=429,
                detail={
                    "error": "Rate limit exceeded",
                    "retry_after": int(60 - (now - self._requests[api_key][0])),
                },
            )

        self._requests[api_key].append(now)
        response = await call_next(request)
        response.headers["X-RateLimit-Remaining"] = str(
            self.rpm - len(self._requests[api_key])
        )
        return response

Step 7: The FastAPI Application

# app/main.py
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import StreamingResponse
from app.config import get_settings
from app.agents.engine import AgentEngine
from app.middleware.rate_limit import RateLimitMiddleware

agent = None

@asynccontextmanager
async def lifespan(app):
    global agent
    agent = AgentEngine()
    yield

app = FastAPI(title="AI Agent API", version="1.0.0", lifespan=lifespan)
settings = get_settings()

app.add_middleware(RateLimitMiddleware,
    requests_per_minute=settings.rate_limit_per_minute)

@app.get("/health")
async def health():
    return {"status": "healthy",
            "active_sessions": agent.sessions.active_sessions}

@app.post("/chat")
async def chat(request, api_key: str = Header(...)):
    return StreamingResponse(
        agent.chat(request.message, request.session_id, request.model),
        media_type="text/event-stream",
    )

Step 8: Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0",
     "--port", "8000", "--workers", "4"]

Deploy It

# Local
uvicorn app.main:app --reload

# Docker
docker compose up --build

# Production: Push to GitHub → Railway/Render/Fly.io

What You Built

A production-ready AI agent API with:

  • Streaming SSE responses — users see output in real-time
  • Session memory — agents remember context across requests
  • Tool calling — search, code execution, URL fetching
  • Rate limiting — per-API-key token bucket
  • API key auth — simple but effective
  • Structured logging — know what's happening
  • Docker support — deploy anywhere
  • Health endpoint — monitoring-ready

This is the same architecture pattern we use at ZOO for client projects.

Need Help Deploying Your AI Agent?

We help startups build production-ready AI systems — from agent architecture to deployment and monitoring.

Get a Free 30-Min Architecture Review →

No strings attached. We'll review your architecture and give you a deployment roadmap.