Building AI Agents with Open Source Models
Track 7: Production & Deployment OS Track · Module 19 of 20
⏱ 80 min 📊 Intermediate
🦎
Open Source Track — M21 All code runs against a local Ollama sidecar — no GPU required for Mistral-7B-Q4 on an 8 GB VPS. Docker Compose wires the FastAPI service and the Ollama container together on a private network so no API keys ever leave your machine.

M21: API Design & Deployment

Your agent works in a notebook. Now make it work everywhere — as a proper HTTP service with streaming responses, async job queues for long-running work, rate limiting, and a reproducible Docker deployment that any developer can clone and run.

Learning Objectives

  • Explain why a stateless HTTP wrapper is the right architecture for an agent service
  • Build a FastAPI application with CORS, API key auth, health check, and error middleware
  • Implement a streaming endpoint using SSE and wire a JS EventSource client to it
  • Add an async job queue pattern for agents that run longer than 30 seconds
  • Apply Pydantic validation and a token-bucket rate limiter to protect the service
  • Build a multi-stage Dockerfile and docker-compose.yml with an Ollama sidecar
  • Deploy the same Docker image to Hetzner/DigitalOcean, Fly.io, and Railway
  • Expose Prometheus metrics from M20 on a /metrics endpoint in two lines

Wrapping an Agent as an HTTP Service

Before the Pain

Imagine you have built a brilliant research analyst who lives in a locked room. She can answer any question, use any tool, and produce extraordinary results — but the only way to work with her is to walk into that room, hand her a sheet of paper, and wait until she slides the answer back under the door. No other team can access her simultaneously, no system can call her programmatically, and her knowledge of the conversation ends the moment you leave.

The pain is obvious the moment a second team needs the same analyst: they cannot get in, or they must wait while you are already inside. Any automated pipeline that needs her output is blocked on a human gatekeeper. Every call requires a full context handshake from scratch because she remembers nothing between visits. The analyst herself is great; the access model is broken.

The intercom is your HTTP API. It lets any caller — another service, a UI, a cron job — communicate with the agent without entering the room. Each call is independent (stateless), so two callers can reach her at the same time without conflict. The session ID on each message is the label on the envelope: the caller owns the memory, not the analyst. Wrapping your agent as a service does not change what it does; it changes who can reach it and how reliably.

Three Design Principles for Agent APIs

Definition: Stateless Endpoints

A stateless endpoint means the server holds zero session state between requests. Every HTTP call must include everything the agent needs: the query, the conversation history (or a session ID to look it up in a store), and any configuration. This makes horizontal scaling trivial — any replica can handle any request — and eliminates an entire class of sticky-session bugs. The caller, not the server, is responsible for threading context across calls.

Definition: SSE — Server-Sent Events

SSEServer-Sent Events: a browser-native protocol where the server pushes a stream of text events over a single long-lived HTTP connection. Each event has a type and data field. The browser's EventSource API reconnects automatically on disconnect. (Server-Sent Events) is a browser-native streaming protocol: the server sends a continuous stream of data: ...\n\n chunks over one long-lived HTTP connection. Unlike WebSockets, SSE is unidirectional (server to client), works through every HTTP proxy, and requires no special setup beyond a Content-Type: text/event-stream header. For agent APIs, SSE is ideal for streaming intermediate steps — thoughts, tool calls, partial results — as they happen.

Definition: Idempotency Key

An idempotency keyA unique client-generated token (UUID) sent in the request header. If the client retries a failed request with the same key, the server recognizes it and returns the cached result instead of running the agent twice. Prevents duplicate billing or duplicate record creation on network failures. is a unique token the client generates and sends with each request (typically as an X-Idempotency-Key header). If a network timeout causes the client to retry, the server detects the duplicate key and returns the already-completed result rather than running the agent again. Without idempotency keys, a client retry during a network blip can trigger the agent twice — double-billing, duplicate writes, or conflicting results.

Why It Matters: Real Deployment Numbers

A FastAPI service wrapping a Mistral-7B-Q4 agent on a Hetzner CX21 (2 vCPU, 4 GB RAM, €3.79/mo) can sustain ~12 concurrent streaming requests with sub-500ms first-token latency. The same agent running as a library function, accessed by multiple callers sharing a Python process, will serialize all calls through the GIL and deliver ~1 concurrent request at 8× the latency. The API wrapper is not overhead — it is what makes concurrency possible.

Response Strategies — Timeline Comparison
Sync
▶ wait 45s → 200 OK {result}
Client blocks entire duration. Proxy timeout risk >30s.
Streaming
▶ first chunk in <1s → events... → done
First token fast. Ideal for <60s agents with UI progress.
Async Queue
▶ 202 instant → poll GET /jobs/id → result
Best for >60s work. No proxy timeout. Horizontal scale.
Synchronous (blocks)
SSE Streaming (progressive)
Async Queue (fire-and-poll)
Three strategies: Sync (client blocks, risky >30s), Streaming SSE (first byte fast), Async Queue (202 + poll, no timeout).
With the architecture mapped out, let's build the actual service. We will start with a complete FastAPI skeleton that handles auth, errors, and health checks before we add any agent logic.

FastAPI Boilerplate

A production-grade FastAPI service needs five things before you add a single line of agent logic: CORS headers so browsers can call it, Pydantic request/response models so the API is self-documenting, an error middleware to convert exceptions into clean JSON, a health check endpoint for load balancers, and API key authentication to prevent abuse.

Request and Response Models

WHAT: Pydantic models define the contract — query + session_id in, result + metadata out
WHY: FastAPI auto-validates, auto-documents, and auto-serializes these — zero boilerplate JSON parsing
GOTCHA: session_id is optional here; if None, each request starts a fresh context (stateless by design)
# src/models.py
# WHAT: Typed request and response contracts for the agent API
# WHY: Pydantic catches malformed inputs before they reach the agent,
#      and FastAPI generates OpenAPI docs from these classes automatically
# GOTCHA: Use Optional[str] not str | None for Python < 3.10 compatibility

from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Any
import uuid

class AgentRequest(BaseModel):
    """Incoming agent invocation request."""
    query: str = Field(
        ...,                              # required
        min_length=1,
        max_length=4096,
        description="The question or task to send to the agent",
    )
    session_id: Optional[str] = Field(
        default=None,
        description="Resume a prior conversation. None = fresh context.",
    )
    max_iterations: int = Field(
        default=8,
        ge=1,
        le=20,
        description="Max ReAct loop iterations before forced stop",
    )
    stream: bool = Field(
        default=False,
        description="If true, return SSE stream instead of JSON blob",
    )

    @field_validator("query")
    @classmethod
    def strip_query(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("query must not be empty after stripping whitespace")
        return v


class ToolCallRecord(BaseModel):
    """One tool invocation captured from the agent loop."""
    tool_name: str
    input_summary: str       # truncated for log safety
    output_summary: str
    duration_ms: int


class AgentResponse(BaseModel):
    """Successful synchronous agent response."""
    result: str
    session_id: str = Field(
        default_factory=lambda: str(uuid.uuid4()),
        description="Use this in the next request to continue the conversation",
    )
    iterations: int          = Field(description="ReAct loop iterations used")
    tool_calls: List[ToolCallRecord] = Field(default_factory=list)
    latency_ms: int          = Field(description="Total wall-clock time")
    model: str               = Field(description="Ollama model used")


class ErrorResponse(BaseModel):
    """Standard error envelope returned on 4xx/5xx."""
    error: str
    detail: Optional[str] = None
    request_id: Optional[str] = None
// src/models.js  (Zod schemas for Express)
// WHAT: Same request/response contracts in Zod for Node.js
// WHY: Zod gives runtime validation + TypeScript types from one schema
// GOTCHA: z.string().uuid() is strict — pass null for fresh sessions,
//         not an empty string

import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';

export const AgentRequestSchema = z.object({
  query: z
    .string()
    .min(1, 'query must not be empty')
    .max(4096)
    .transform((s) => s.trim()),
  session_id: z.string().uuid().nullable().default(null),
  max_iterations: z.number().int().min(1).max(20).default(8),
  stream: z.boolean().default(false),
});

export const ToolCallRecordSchema = z.object({
  tool_name: z.string(),
  input_summary: z.string(),
  output_summary: z.string(),
  duration_ms: z.number().int(),
});

export const AgentResponseSchema = z.object({
  result: z.string(),
  session_id: z.string().uuid().default(() => uuidv4()),
  iterations: z.number().int(),
  tool_calls: z.array(ToolCallRecordSchema).default([]),
  latency_ms: z.number().int(),
  model: z.string(),
});

export const ErrorResponseSchema = z.object({
  error: z.string(),
  detail: z.string().nullable().default(null),
  request_id: z.string().nullable().default(null),
});

/** @typedef {z.infer<typeof AgentRequestSchema>} AgentRequest */
/** @typedef {z.infer<typeof AgentResponseSchema>} AgentResponse */

Full Application Skeleton

WHAT: CORS, API key auth middleware, global error handler, /health endpoint
WHY: Load balancers probe /health every 10s — must return 200 or the instance is removed from rotation
GOTCHA: API_KEY must be set in the environment; the app refuses to start if missing
# src/main.py
# WHAT: FastAPI app with CORS, auth, error middleware, and health check
# WHY: Each concern is a separate middleware layer so they can be
#      tested and replaced independently
# GOTCHA: Place the error handler FIRST (outermost) so it catches
#         exceptions from ALL inner middleware, not just route handlers

import os, time, uuid, logging
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .models import AgentRequest, AgentResponse, ErrorResponse
from .agent import run_agent   # your ReAct loop from Capstone C3

logger = logging.getLogger(__name__)

API_KEY = os.environ.get("API_KEY")
if not API_KEY:
    raise RuntimeError("API_KEY environment variable must be set")

OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")

app = FastAPI(
    title="Entity Resolution Agent API",
    version="1.0.0",
    docs_url="/docs",          # Swagger UI
    redoc_url="/redoc",
)

# ── CORS ──────────────────────────────────────────────────────────────
# WHAT: Allow browser frontends to call this API directly
# GOTCHA: In production, replace allow_origins=["*"] with your
#         actual frontend domain to prevent cross-origin abuse
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],       # tighten in production
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["*"],
)

# ── API KEY AUTH ───────────────────────────────────────────────────────
async def verify_api_key(request: Request) -> str:
    """Dependency: extract and validate the Bearer token."""
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing Bearer token")
    token = auth.removeprefix("Bearer ").strip()
    if token != API_KEY:
        raise HTTPException(status_code=401, detail="Invalid API key")
    return token

# ── GLOBAL ERROR HANDLER ───────────────────────────────────────────────
@app.exception_handler(Exception)
async def global_error_handler(request: Request, exc: Exception):
    request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
    logger.exception("Unhandled error on %s (req=%s)", request.url, request_id)
    return JSONResponse(
        status_code=500,
        content=ErrorResponse(
            error="internal_server_error",
            detail=str(exc) if os.environ.get("DEBUG") else None,
            request_id=request_id,
        ).model_dump(),
    )

# ── HEALTH CHECK ───────────────────────────────────────────────────────
@app.get("/health", tags=["ops"])
async def health():
    """Load balancer probe — returns 200 if the service is alive."""
    import httpx
    try:
        r = httpx.get(f"{OLLAMA_HOST}/api/tags", timeout=3)
        ollama_ok = r.status_code == 200
    except Exception:
        ollama_ok = False
    return {
        "status": "ok" if ollama_ok else "degraded",
        "ollama": ollama_ok,
        "version": app.version,
    }

# ── AGENT ROUTE ────────────────────────────────────────────────────────
@app.post(
    "/agent/run",
    response_model=AgentResponse,
    responses={422: {"model": ErrorResponse}, 429: {"model": ErrorResponse}},
    dependencies=[Depends(verify_api_key)],
    tags=["agent"],
)
async def run_agent_endpoint(body: AgentRequest, request: Request):
    """Synchronous agent invocation. For long queries use /agent/jobs."""
    start = time.perf_counter()
    result_data = await run_agent(
        query=body.query,
        session_id=body.session_id,
        max_iterations=body.max_iterations,
        ollama_host=OLLAMA_HOST,
    )
    latency_ms = int((time.perf_counter() - start) * 1000)
    return AgentResponse(latency_ms=latency_ms, **result_data)
// src/app.js  (Express)
// WHAT: Express app with CORS, API key auth, error handler, health check
// WHY: Mirrors the Python FastAPI structure so both deploy identically
// GOTCHA: express-async-errors must be required BEFORE route definitions
//         to patch async route handlers with automatic error forwarding

import 'express-async-errors';        // patches async route handlers
import express from 'express';
import cors from 'cors';
import { v4 as uuidv4 } from 'uuid';
import { AgentRequestSchema } from './models.js';
import { runAgent } from './agent.js';
import fetch from 'node-fetch';

const API_KEY = process.env.API_KEY;
if (!API_KEY) throw new Error('API_KEY environment variable must be set');

const OLLAMA_HOST = process.env.OLLAMA_HOST ?? 'http://localhost:11434';

export const app = express();

// ── CORS ──────────────────────────────────────────────────────────────
app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'] }));
app.use(express.json({ limit: '1mb' }));

// ── API KEY AUTH MIDDLEWARE ───────────────────────────────────────────
app.use((req, res, next) => {
  if (req.path === '/health') return next();   // exempt health probe
  const auth = req.headers['authorization'] ?? '';
  if (!auth.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing Bearer token' });
  }
  if (auth.slice(7).trim() !== API_KEY) {
    return res.status(401).json({ error: 'Invalid API key' });
  }
  next();
});

// ── HEALTH CHECK ──────────────────────────────────────────────────────
app.get('/health', async (req, res) => {
  let ollamaOk = false;
  try {
    const r = await fetch(`${OLLAMA_HOST}/api/tags`, { signal: AbortSignal.timeout(3000) });
    ollamaOk = r.ok;
  } catch { /* degraded */ }
  res.json({ status: ollamaOk ? 'ok' : 'degraded', ollama: ollamaOk, version: '1.0.0' });
});

// ── AGENT ROUTE ────────────────────────────────────────────────────────
app.post('/agent/run', async (req, res) => {
  const parsed = AgentRequestSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(422).json({ error: 'validation_error', detail: parsed.error.format() });
  }
  const { query, session_id, max_iterations } = parsed.data;
  const start = Date.now();
  const resultData = await runAgent({ query, sessionId: session_id, maxIterations: max_iterations, ollamaHost: OLLAMA_HOST });
  return res.json({ ...resultData, latency_ms: Date.now() - start });
});

// ── GLOBAL ERROR HANDLER ──────────────────────────────────────────────
app.use((err, req, res, _next) => {
  const requestId = req.headers['x-request-id'] ?? uuidv4();
  console.error(`[${requestId}] Unhandled error:`, err);
  res.status(500).json({
    error: 'internal_server_error',
    detail: process.env.DEBUG ? err.message : undefined,
    request_id: requestId,
  });
});
What Just Happened?

You now have a properly layered API skeleton. CORS is outermost so preflight requests are handled before auth runs. Auth is a FastAPI Dependency (or Express middleware) so it composes onto any route without repetition. The global error handler logs with a request ID so you can correlate API logs with Prometheus traces. The health endpoint checks the Ollama sidecar so load balancers know when the service is truly ready.

The synchronous endpoint blocks until the agent finishes. For a 10-second agent that's fine. For a 45-second agent traversing hundreds of UCC records, clients will time out. Let's add streaming.

Streaming with SSE

When you stream, the server sends one SSE event per step of the ReAct loop: a thought event when the model reasons, a tool_call event when it invokes a tool, and a final result event when the loop terminates. The client renders each event immediately, so the user sees progress instead of a spinning indicator.

Definition: SSE Event Format

Every SSEServer-Sent Events: HTTP/1.1 streaming protocol. The server writes "data: ...\n\n" chunks continuously. The browser EventSource reconnects automatically. No WebSocket upgrade needed. event is two lines: event: thought\n then data: {"content":"..."}\n\n. The double newline signals the end of one event. The event: field is the type (optional but recommended); the data: field is the payload. You can also include id: for client-side reconnect position tracking and retry: to control reconnect interval.

WHAT: StreamingResponse that yields one SSE event per ReAct step
WHY: Clients get first token in <1s regardless of total agent runtime
GOTCHA: Set media_type="text/event-stream" AND include Cache-Control: no-cache or Nginx will buffer the entire stream before forwarding
# src/streaming.py
# WHAT: SSE endpoint that emits thought / tool_call / result events
# WHY: ReAct agents can take 30-90s; streaming keeps the UI alive
# GOTCHA: async generators must use "yield" not "return"; returning
#         from an async generator closes the stream cleanly

import json, time
from fastapi import APIRouter, Request, Depends
from fastapi.responses import StreamingResponse
from .models import AgentRequest
from .main import verify_api_key, OLLAMA_HOST

router = APIRouter()

async def agent_event_generator(body: AgentRequest):
    """
    WHAT: Converts the ReAct loop into an async SSE generator.
    WHY:  FastAPI's StreamingResponse consumes this generator and
          writes each yielded string directly to the HTTP response
          without buffering the whole output.
    GOTCHA: Every chunk MUST end with \n\n (two newlines). One newline
            splits fields within an event; two newlines end the event.
    """
    from .agent import stream_agent   # yields step dicts

    try:
        async for step in stream_agent(
            query=body.query,
            session_id=body.session_id,
            max_iterations=body.max_iterations,
            ollama_host=OLLAMA_HOST,
        ):
            step_type = step.get("type", "unknown")
            payload = json.dumps(step, ensure_ascii=False)

            # SSE wire format: "event: {type}\ndata: {json}\n\n"
            yield f"event: {step_type}\ndata: {payload}\n\n"

    except Exception as e:
        # Send a final error event before closing the stream
        error_payload = json.dumps({"error": str(e)})
        yield f"event: error\ndata: {error_payload}\n\n"


@router.post(
    "/agent/stream",
    dependencies=[Depends(verify_api_key)],
    tags=["agent"],
    summary="Stream agent steps as SSE",
)
async def stream_agent_endpoint(body: AgentRequest, request: Request):
    """
    WHAT: Wraps the async generator in FastAPI's StreamingResponse
    GOTCHA: headers must include Cache-Control: no-cache or reverse
            proxies (Nginx, Caddy) will buffer the stream
    """
    return StreamingResponse(
        agent_event_generator(body),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",   # disables Nginx proxy buffering
            "Connection": "keep-alive",
        },
    )
// src/routes/stream.js  (Express)
// WHAT: SSE streaming route for the ReAct agent loop
// WHY: res.write() sends each chunk immediately; res.end() closes the stream
// GOTCHA: Must call res.flushHeaders() before the first res.write()
//         or Nginx will buffer until the response ends

import express from 'express';
import { streamAgent } from '../agent.js';

export const streamRouter = express.Router();

streamRouter.post('/agent/stream', async (req, res) => {
  // WHAT: Set SSE headers before any data is written
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('X-Accel-Buffering', 'no');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();   // flush headers to client immediately

  const { query, session_id, max_iterations } = req.body ?? {};
  if (!query) {
    res.write(`event: error\ndata: ${JSON.stringify({ error: 'query required' })}\n\n`);
    return res.end();
  }

  try {
    // WHAT: streamAgent() is an async generator yielding step objects
    // GOTCHA: for-await-of automatically calls .return() on break/throw
    for await (const step of streamAgent({ query, sessionId: session_id, maxIterations: max_iterations ?? 8 })) {
      const eventType = step.type ?? 'unknown';
      const payload   = JSON.stringify(step);
      res.write(`event: ${eventType}\ndata: ${payload}\n\n`);
    }
  } catch (err) {
    res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
  }

  res.end();
});

JavaScript EventSource Client

WHAT: Browser-side EventSource that renders agent steps in real-time
WHY: EventSource reconnects automatically on network drops — no heartbeat polling needed
GOTCHA: EventSource only supports GET; for POST bodies use fetch with ReadableStream instead
// client/stream.js  (browser)
// WHAT: POST to /agent/stream then read SSE events progressively
// WHY: Native EventSource only does GET; we use fetch + ReadableStream
//      for POST bodies so we can send { query, session_id }
// GOTCHA: TextDecoderStream is available in all modern browsers;
//         for Safari < 14.1, polyfill with text-encoding-utf-8

async function streamAgentQuery(query, sessionId = null) {
  const outputDiv = document.getElementById('agent-output');
  outputDiv.innerHTML = '';   // clear previous run

  const response = await fetch('/agent/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${window.API_KEY}`,
    },
    body: JSON.stringify({ query, session_id: sessionId, max_iterations: 8 }),
  });

  if (!response.ok) {
    outputDiv.textContent = `Error: ${response.status}`;
    return;
  }

  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();

  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += value;
    // SSE events are terminated by double newline
    const events = buffer.split('\n\n');
    buffer = events.pop();   // keep incomplete event in buffer

    for (const raw of events) {
      if (!raw.trim()) continue;

      // Parse "event: TYPE\ndata: JSON"
      const lines  = raw.split('\n');
      const evType = lines.find(l => l.startsWith('event:'))?.slice(6).trim() ?? 'unknown';
      const dataLine = lines.find(l => l.startsWith('data:'))?.slice(5).trim() ?? '{}';

      let step;
      try { step = JSON.parse(dataLine); } catch { step = { raw: dataLine }; }

      renderStep(evType, step, outputDiv);
    }
  }
}

function renderStep(type, step, container) {
  const div = document.createElement('div');
  div.className = `sse-event sse-${type}`;

  switch (type) {
    case 'thought':
      div.innerHTML = `Thought: ${escapeHtml(step.content ?? '')}`;
      break;
    case 'tool_call':
      div.innerHTML = `Tool: ${escapeHtml(step.tool ?? '')} → ${escapeHtml(step.summary ?? '')}`;
      break;
    case 'result':
      div.innerHTML = `Result: ${escapeHtml(step.content ?? '')}`;
      div.style.borderColor = '#10B981';
      break;
    case 'error':
      div.innerHTML = `Error: ${escapeHtml(step.error ?? '')}`;
      div.style.borderColor = '#F43F5E';
      break;
    default:
      div.textContent = JSON.stringify(step);
  }

  container.appendChild(div);
  div.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}

function escapeHtml(s) {
  return s.replace(/&/g,'&').replace(//g,'>');
}
// client/stream.node.js  (Node.js CLI consumer)
// WHAT: Consume the SSE stream from Node.js using undici fetch
// WHY: Useful for CLI tools, test scripts, or backend-to-backend calls
// GOTCHA: Node.js built-in fetch (v18+) supports streaming;
//         for v16, use node-fetch@3 which has the same API

import { fetch } from 'undici';  // or: import fetch from 'node-fetch';

async function streamFromNode(query, apiKey) {
  const res = await fetch('http://localhost:8000/agent/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({ query, max_iterations: 8 }),
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const decoder = new TextDecoder();
  let buffer = '';

  for await (const chunk of res.body) {
    buffer += decoder.decode(chunk, { stream: true });
    const events = buffer.split('\n\n');
    buffer = events.pop();

    for (const raw of events) {
      if (!raw.trim()) continue;
      const lines = raw.split('\n');
      const type = lines.find(l => l.startsWith('event:'))?.slice(6).trim() ?? 'data';
      const data = lines.find(l => l.startsWith('data:'))?.slice(5).trim() ?? '{}';
      let step;
      try { step = JSON.parse(data); } catch { step = { raw: data }; }
      console.log(`[${type}]`, JSON.stringify(step));
    }
  }
}

streamFromNode(process.argv[2] ?? 'Resolve Acme Corp', process.env.API_KEY)
  .catch(console.error);
What Just Happened?

The streaming endpoint converts the agent's ReAct loop into a live SSE feed. Each iteration produces at least one thought and one tool_call event before the final result. The client renders each event immediately. A 45-second agent now shows the first step in under 1 second — radically better perceived performance and far better resilience to network timeouts.

Streaming solves latency for agents under 60 seconds. But what about a bulk UCC enrichment task that processes 10,000 records? No HTTP connection lasts that long. We need an async job queue.

Async Job Queue for Long-Running Agents

Definition: 202 Accepted

HTTP 202 AcceptedHTTP status code meaning "the request was accepted for processing, but processing has not been completed." The server returns a job ID and the client polls a separate endpoint to get the eventual result. Used when work takes longer than a client can reasonably wait. means "I received your request and will process it, but I have not finished yet." The server immediately returns a job ID. The client polls GET /jobs/{job_id} until it sees status: complete. This breaks the synchronous request-response link, so agents can run for minutes or hours without a proxy timeout ever occurring.

Simple In-Process Queue (asyncio)

WHAT: POST /agent/jobs returns 202 instantly; GET /jobs/{id} polls for result
WHY: Breaks the 30s proxy timeout barrier; jobs can run for minutes
GOTCHA: asyncio.Queue lives in memory — all jobs vanish on restart. Use Redis+rq for production durability
# src/jobs.py
# WHAT: In-process asyncio job queue — simple version for dev/staging
# WHY: Zero dependencies; good enough for low-volume or demo deployments
# GOTCHA: State is in-memory. On container restart, pending jobs are lost.
#         For production, replace job_store with Redis and use rq or Celery.

import asyncio, uuid, time
from enum import Enum
from typing import Any, Optional, Dict
from fastapi import APIRouter, BackgroundTasks, Depends
from .models import AgentRequest
from .main import verify_api_key, OLLAMA_HOST
from .agent import run_agent

router = APIRouter()

class JobStatus(str, Enum):
    PENDING   = "pending"
    RUNNING   = "running"
    COMPLETE  = "complete"
    FAILED    = "failed"

# WHAT: In-memory job store — replace with Redis hashes in production
job_store: Dict[str, Dict[str, Any]] = {}


async def _execute_job(job_id: str, body: AgentRequest) -> None:
    """Background coroutine that runs the agent and updates job_store."""
    job_store[job_id]["status"] = JobStatus.RUNNING
    job_store[job_id]["started_at"] = time.time()
    try:
        result = await run_agent(
            query=body.query,
            session_id=body.session_id,
            max_iterations=body.max_iterations,
            ollama_host=OLLAMA_HOST,
        )
        job_store[job_id].update({
            "status": JobStatus.COMPLETE,
            "result": result,
            "finished_at": time.time(),
        })
    except Exception as e:
        job_store[job_id].update({
            "status": JobStatus.FAILED,
            "error": str(e),
            "finished_at": time.time(),
        })


@router.post(
    "/agent/jobs",
    status_code=202,
    dependencies=[Depends(verify_api_key)],
    tags=["agent"],
    summary="Submit a long-running agent job",
)
async def submit_job(body: AgentRequest, background_tasks: BackgroundTasks):
    """
    WHAT: Enqueue job, return job_id immediately with 202 Accepted.
    GOTCHA: BackgroundTasks runs after the response is sent but WITHIN
            the same process. For true isolation, use rq or Celery.
    """
    job_id = str(uuid.uuid4())
    job_store[job_id] = {
        "job_id": job_id,
        "status": JobStatus.PENDING,
        "query": body.query,
        "created_at": time.time(),
    }
    background_tasks.add_task(_execute_job, job_id, body)
    return {"job_id": job_id, "status": JobStatus.PENDING, "poll_url": f"/jobs/{job_id}"}


@router.get(
    "/jobs/{job_id}",
    dependencies=[Depends(verify_api_key)],
    tags=["agent"],
    summary="Poll job status and retrieve result",
)
async def get_job(job_id: str):
    """
    WHAT: Returns current job state. Client polls every 2s until complete.
    GOTCHA: Return 404 for unknown IDs so clients detect bad job_ids
            rather than polling forever on a silent empty response.
    """
    job = job_store.get(job_id)
    if not job:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
    return job
// src/routes/jobs.js  (BullMQ + Redis)
// WHAT: Production-grade job queue using BullMQ backed by Redis
// WHY: Jobs survive process restarts; workers scale horizontally
// GOTCHA: Requires Redis. Set REDIS_URL in env. For local dev,
//         run: docker run -p 6379:6379 redis:alpine

import express from 'express';
import { Queue, Worker, QueueEvents } from 'bullmq';
import IORedis from 'ioredis';
import { v4 as uuidv4 } from 'uuid';
import { runAgent } from '../agent.js';

const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
  maxRetriesPerRequest: null,   // BullMQ requirement
});

const agentQueue = new Queue('agent-jobs', { connection });

// WHAT: Worker runs in the same process (or a separate file for scale-out)
// GOTCHA: Worker must be instantiated for jobs to process.
//         In production, run workers as a separate Node.js process.
const worker = new Worker('agent-jobs', async (job) => {
  const { query, sessionId, maxIterations } = job.data;
  return runAgent({ query, sessionId, maxIterations, ollamaHost: process.env.OLLAMA_HOST ?? 'http://localhost:11434' });
}, { connection, concurrency: 4 });

worker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message);
});

export const jobsRouter = express.Router();

// POST /agent/jobs → 202 Accepted with job_id
jobsRouter.post('/agent/jobs', async (req, res) => {
  const { query, session_id, max_iterations = 8 } = req.body ?? {};
  if (!query) return res.status(422).json({ error: 'query required' });

  const job = await agentQueue.add('run', {
    query, sessionId: session_id, maxIterations: max_iterations,
  }, {
    attempts: 2,
    backoff: { type: 'exponential', delay: 2000 },
  });

  res.status(202).json({
    job_id: job.id,
    status: 'pending',
    poll_url: `/jobs/${job.id}`,
  });
});

// GET /jobs/:id → {status, result, error}
jobsRouter.get('/jobs/:id', async (req, res) => {
  const job = await agentQueue.getJob(req.params.id);
  if (!job) return res.status(404).json({ error: `Job ${req.params.id} not found` });

  const state  = await job.getState();          // waiting | active | completed | failed
  const result = state === 'completed' ? await job.returnvalue : undefined;
  const error  = state === 'failed'    ? job.failedReason : undefined;

  res.json({ job_id: job.id, status: state, result, error });
});
$ curl -s -X POST http://localhost:8000/agent/jobs \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"query":"Resolve all Acme Corp filings in batch","max_iterations":15}' | jq . { "job_id": "3f9c2a1b-8e7d-4f2a-b3c1-9d0e5f6a7b8c", "status": "pending", "poll_url": "/jobs/3f9c2a1b-8e7d-4f2a-b3c1-9d0e5f6a7b8c" } $ curl -s http://localhost:8000/jobs/3f9c2a1b-... | jq .status "running" $ curl -s http://localhost:8000/jobs/3f9c2a1b-... | jq .status "complete"
We have three ways to call the agent. Now let's protect the service from malformed inputs and from callers who send too many requests.

Validation & Rate Limiting

Definition: Token Bucket Rate Limiter

A token bucketA rate limiting algorithm: each API key has a "bucket" with a maximum capacity (e.g., 10 tokens). Each request consumes one token. Tokens refill at a constant rate (e.g., 1 per 6 seconds = 10/min). If the bucket is empty, the request is rejected with 429. Allows short bursts up to bucket capacity while enforcing a long-term average rate. rate limiter gives each API key a bucket with N tokens. Each request consumes one token; tokens refill at a constant rate. A key can burst up to bucket capacity then is throttled to the refill rate. This is friendlier than a hard per-minute counter (which punishes clients that legitimately front-load requests) while still enforcing an average rate ceiling. The standard response for a refused request is HTTP 429 with a Retry-After header.

WHAT: Token bucket enforcing 10 req/min per API key; HTTP 429 with Retry-After on breach
WHY: Prevents a single noisy caller from consuming all Ollama inference capacity
GOTCHA: This in-process bucket is per-worker; behind a multi-replica deployment, use Redis + lua script for shared state
# src/rate_limit.py
# WHAT: In-process token bucket rate limiter, 10 req/min per API key
# WHY: Protect Ollama from inference overload caused by a noisy caller
# GOTCHA: Use a threading.Lock (or asyncio.Lock) around bucket state;
#         async routes run concurrently so unsynchronized reads are a race

import time, math
from threading import Lock
from typing import Dict
from fastapi import HTTPException, Request

class TokenBucket:
    """Thread-safe token bucket. Capacity=10, refill=10/min."""

    def __init__(self, capacity: int = 10, refill_rate: float = 10 / 60):
        self.capacity    = capacity
        self.refill_rate = refill_rate          # tokens per second
        self._buckets: Dict[str, Dict] = {}
        self._lock = Lock()

    def _get_or_create(self, key: str) -> Dict:
        if key not in self._buckets:
            self._buckets[key] = {"tokens": self.capacity, "last": time.monotonic()}
        return self._buckets[key]

    def consume(self, key: str) -> float:
        """
        WHAT: Try to consume one token. Returns seconds until retry if refused.
        WHY:  Returns a float so the caller can set Retry-After header precisely.
        GOTCHA: math.floor for tokens avoids sub-token accumulation drift.
        """
        with self._lock:
            bucket = self._get_or_create(key)
            now    = time.monotonic()
            elapsed = now - bucket["last"]

            # Refill tokens earned since last call
            new_tokens = elapsed * self.refill_rate
            bucket["tokens"] = min(self.capacity, bucket["tokens"] + new_tokens)
            bucket["last"]   = now

            if bucket["tokens"] >= 1:
                bucket["tokens"] -= 1
                return 0.0          # allowed
            else:
                # seconds until one token is available
                return (1 - bucket["tokens"]) / self.refill_rate


limiter = TokenBucket(capacity=10, refill_rate=10 / 60)


async def rate_limit_dependency(request: Request) -> None:
    """
    WHAT: FastAPI dependency that rejects over-rate requests with 429.
    GOTCHA: Reads the Authorization header to identify the key; use
            a different identifier (IP, user ID) if keys are shared.
    """
    auth = request.headers.get("Authorization", "")
    key  = auth.removeprefix("Bearer ").strip() or request.client.host

    retry_after = limiter.consume(key)
    if retry_after > 0:
        raise HTTPException(
            status_code=429,
            detail={
                "error": "rate_limit_exceeded",
                "message": f"10 req/min limit reached. Retry in {retry_after:.1f}s.",
                "retry_after_seconds": math.ceil(retry_after),
            },
            headers={"Retry-After": str(math.ceil(retry_after))},
        )
// src/middleware/rateLimit.js  (Express)
// WHAT: Token bucket rate limiter — 10 req/min per API key
// WHY: Single-dependency implementation; no Redis needed for single-replica
// GOTCHA: For multi-replica deployments replace with rate-limiter-flexible
//         backed by Redis so all replicas share bucket state

const buckets = new Map();   // key → { tokens, lastMs }

const CAPACITY    = 10;
const REFILL_RATE = 10 / 60;   // tokens per second

function consume(key) {
  const now = Date.now() / 1000;
  let bucket = buckets.get(key);

  if (!bucket) {
    bucket = { tokens: CAPACITY, last: now };
    buckets.set(key, bucket);
  }

  const elapsed = now - bucket.last;
  bucket.tokens = Math.min(CAPACITY, bucket.tokens + elapsed * REFILL_RATE);
  bucket.last   = now;

  if (bucket.tokens >= 1) {
    bucket.tokens -= 1;
    return 0;   // allowed
  }
  return (1 - bucket.tokens) / REFILL_RATE;   // seconds to retry
}

export function rateLimitMiddleware(req, res, next) {
  if (req.path === '/health') return next();

  const auth = req.headers['authorization'] ?? '';
  const key  = auth.startsWith('Bearer ') ? auth.slice(7).trim() : req.ip;

  const retryAfter = consume(key);
  if (retryAfter > 0) {
    const retryAfterCeil = Math.ceil(retryAfter);
    res.setHeader('Retry-After', String(retryAfterCeil));
    return res.status(429).json({
      error: 'rate_limit_exceeded',
      message: `10 req/min limit reached. Retry in ${retryAfterCeil}s.`,
      retry_after_seconds: retryAfterCeil,
    });
  }

  next();
}
HTTP/1.1 422 Unprocessable Entity { "detail": [ { "loc": ["body", "query"], "msg": "String should have at least 1 character", "type": "string_too_short" } ] } HTTP/1.1 429 Too Many Requests Retry-After: 6 { "error": "rate_limit_exceeded", "message": "10 req/min limit reached. Retry in 5.7s.", "retry_after_seconds": 6 }
The service handles requests correctly and rejects bad ones. Now package everything so it runs identically on your laptop, a CI runner, and a cloud VPS.

Docker Deployment

🦎 Ollama as a Docker Sidecar — No GPU Required

Mistral-7B-Q4 (the 4-bit quantized version) runs entirely on CPU inside the Ollama container with ~4.5 GB RAM. A Hetzner CX21 (2 vCPU, 4 GB RAM) is borderline; the CX31 (2 vCPU, 8 GB RAM, €5.77/mo) is the sweet spot. Set OLLAMA_NUM_THREADS=2 to use both cores. No nvidia-docker, no GPU drivers, no cloud GPU costs. Inference takes 2-5 seconds per token on CPU, which is fine for typical agent queries.

Multi-Stage Dockerfile

WHAT: Multi-stage build separates dependency installation from the final runtime image
WHY: The builder stage installs gcc/build-tools; the final stage only copies compiled artifacts — cuts image size by ~60%
GOTCHA: HEALTHCHECK is required for docker-compose depends_on: condition: service_healthy to work
# Dockerfile  (Python FastAPI)
# Stage 1: builder — installs deps with build tools available
FROM python:3.12-slim AS builder
WORKDIR /build

# WHAT: Install only what's needed to compile native extensions (e.g. httptools)
# GOTCHA: --no-install-recommends saves ~80MB; removes man pages, etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc g++ && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: runtime — lean image with only the compiled packages
FROM python:3.12-slim AS runtime
WORKDIR /app

# WHAT: Copy compiled site-packages from builder without gcc
COPY --from=builder /root/.local /root/.local
COPY src/ ./src/
COPY .env.example ./.env.example

# WHAT: Create non-root user to follow least-privilege principle
# GOTCHA: Files copied before this RUN are owned by root; chown them
RUN useradd --no-create-home appuser && chown -R appuser /app
USER appuser

# WHAT: HEALTHCHECK tells Docker and docker-compose when the app is ready
# WHY: depends_on: condition: service_healthy waits for this to pass
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" \
  || exit 1

ENV PATH=/root/.local/bin:$PATH \
    PYTHONUNBUFFERED=1

EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
# Dockerfile  (TypeScript / Node.js Express)
# Stage 1: build — compile TS to JS
FROM node:20-alpine AS builder
WORKDIR /build

# WHAT: Copy package files first so Docker caches the npm install layer
# GOTCHA: Copy package-lock.json too; npm ci requires it
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci --ignore-scripts

COPY src/ ./src/
RUN npx tsc --outDir dist --noEmit false

# Stage 2: runtime — only production deps + compiled JS
FROM node:20-alpine AS runtime
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts

COPY --from=builder /build/dist ./dist

# WHAT: Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
  CMD wget -qO- http://localhost:8000/health || exit 1

EXPOSE 8000
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]

docker-compose.yml with Ollama Sidecar

Docker Compose Service Graph — Startup Sequence
💻 External Client
:8000
api
build: .
port: 8000:8000
depends_on: ollama
HEALTHCHECK
:11434
ollama
image: ollama/ollama
port: 11434:11434
OLLAMA_HOST=0.0.0.0
HEALTHCHECK
🌐 agent-network (bridge)
Docker Compose: client (:8000) → api service (FastAPI) → ollama sidecar (:11434). Both on agent-network bridge. api depends_on ollama health check.
WHAT: Compose file wiring api + ollama sidecar with healthchecks, volumes, and secrets
WHY: depends_on with condition: service_healthy ensures Ollama is ready before the API starts accepting traffic
GOTCHA: OLLAMA_HOST=0.0.0.0 is required inside the ollama container so other containers can connect; default is 127.0.0.1
# docker-compose.yml
# WHAT: Orchestrate api + ollama sidecar on a private bridge network
# WHY: The private network means ollama is NOT exposed to the internet
#      unless you explicitly bind its port — only the api container talks to it
# GOTCHA: The model must be pulled once: docker exec -it ollama ollama pull mistral

services:
  api:
    build:
      context: .
      target: runtime          # use the lean runtime stage
    ports:
      - "8000:8000"
    environment:
      API_KEY:      ${API_KEY}
      OLLAMA_HOST:  http://ollama:11434   # DNS name = service name inside compose
      DEBUG:        "false"
    depends_on:
      ollama:
        condition: service_healthy        # waits for ollama HEALTHCHECK to pass
    networks:
      - agent-network
    restart: unless-stopped
    stop_grace_period: 30s               # WHAT: graceful shutdown window
    # GOTCHA: send SIGTERM, not SIGKILL; uvicorn needs time to finish
    #         in-flight requests. 30s covers even slow Ollama inference.

  ollama:
    image: ollama/ollama:latest
    ports:
      - "11434:11434"                    # expose only for local CLI pulls
    environment:
      OLLAMA_HOST:         "0.0.0.0"    # listen on all interfaces, not just 127.0.0.1
      OLLAMA_NUM_THREADS:  "2"
      OLLAMA_MAX_LOADED_MODELS: "1"
    volumes:
      - ollama-models:/root/.ollama     # persist pulled models across restarts
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s                  # Ollama needs ~15s to initialize
    networks:
      - agent-network
    restart: unless-stopped

volumes:
  ollama-models:                         # named volume; survives docker-compose down

networks:
  agent-network:
    driver: bridge
# 1. Build and start all services
API_KEY=my-secret-key docker-compose up --build -d

# 2. Pull the Mistral model into the ollama sidecar (one-time setup)
docker-compose exec ollama ollama pull mistral:7b-instruct-q4_K_M

# 3. Watch logs from both services
docker-compose logs -f

# 4. Verify health
curl http://localhost:8000/health
# {"status":"ok","ollama":true,"version":"1.0.0"}

# 5. Test the agent
curl -X POST http://localhost:8000/agent/run \
  -H "Authorization: Bearer my-secret-key" \
  -H "Content-Type: application/json" \
  -d '{"query":"Is Acme Corp LLC the same as ACME CORPORATION?"}'

# 6. Graceful shutdown (waits for in-flight requests to complete)
docker-compose down
What Just Happened?

You now have a fully containerized deployment. The api service never starts until the ollama container passes its health check. Models are stored in a named volume so you only pull once. The agent-network bridge means Ollama is private to the compose stack — it is not reachable from the internet unless you explicitly expose port 11434. Graceful shutdown gives in-flight agent calls 30 seconds to finish before the process is killed.

VPS & Cloud Deployment

The same docker-compose.yml you tested locally deploys to any server that runs Docker. The only three things that change between local and production: TLS termination, secret injection, and Ollama's listen address.

€5.77
Hetzner CX31/mo — 2 vCPU, 8 GB RAM, 80 GB SSD
~$6
DigitalOcean Droplet/mo — equivalent 2 vCPU, 4 GB RAM
free
Fly.io Hobby tier — shared CPU, 256 MB (Ollama needs paid plan)

Option 1: Hetzner / DigitalOcean VPS

WHAT: Server bootstrap: Docker, Caddy (reverse proxy + auto-TLS), deploy script
WHY: Caddy auto-provisions Let's Encrypt certificates and handles TLS termination so your FastAPI app stays plain HTTP internally
GOTCHA: Port 80 and 443 must be open in the server firewall or Caddy cannot complete the ACME challenge
#!/bin/bash
# scripts/provision.sh — run once on a fresh Ubuntu 22.04 server
# WHAT: Install Docker, Caddy, pull repo, inject secrets, start stack
# GOTCHA: Run as root or prefix commands with sudo

set -euo pipefail

# 1. Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER

# 2. Install Caddy (reverse proxy with auto-TLS via Let's Encrypt)
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update && apt-get install -y caddy

# 3. Clone repo and create .env
git clone https://github.com/your-org/entity-agent.git /opt/agent
cd /opt/agent

# 4. Inject secrets (never commit these to git!)
cat > .env <
# /etc/caddy/Caddyfile
# WHAT: Caddy terminates TLS and proxies HTTPS→HTTP to FastAPI
# WHY: Caddy auto-renews Let's Encrypt certs; zero certificate management
# GOTCHA: Replace agent.yourdomain.com with your actual domain.
#         The ACME challenge requires port 80 open to the internet.

agent.yourdomain.com {
    # WHAT: Automatic HTTPS — Caddy provisions and renews the cert
    tls {
        protocols tls1.2 tls1.3   # disable TLS 1.0 / 1.1
    }

    # WHAT: Proxy all requests to the FastAPI container on :8000
    reverse_proxy localhost:8000 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        flush_interval -1           # disable buffering for SSE streams
    }

    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        -Server                     # remove Caddy version header
    }

    # Rate limit logs (Caddy does not rate-limit; your app handles it)
    log {
        output file /var/log/caddy/agent-access.log
        format json
    }
}

Option 2: Fly.io

WHAT: Fly.io config for the FastAPI app; Ollama runs as a separate Fly Machine
GOTCHA: Mistral-7B-Q4 needs 5 GB RAM minimum; use fly scale memory 6144 on the Ollama machine
# fly.toml  (generated by fly launch, then customized)
app            = "entity-agent"
primary_region = "iad"   # us-east

[build]
  dockerfile = "Dockerfile"

[env]
  OLLAMA_HOST = "http://entity-agent-ollama.internal:11434"
  DEBUG       = "false"

[[services]]
  protocol   = "tcp"
  internal_port = 8000

  [[services.ports]]
    port     = 443
    handlers = ["tls", "http"]

  [[services.ports]]
    port     = 80
    handlers = ["http"]

  [services.concurrency]
    type       = "requests"
    hard_limit = 25
    soft_limit = 20

  [[services.http_checks]]
    interval     = "15s"
    timeout      = "5s"
    grace_period = "30s"
    path         = "/health"

[[vm]]
  size   = "shared-cpu-2x"
  memory = "2gb"
# Deploy to Fly.io
# GOTCHA: Install flyctl first: curl -L https://fly.io/install.sh | sh

# 1. Authenticate
flyctl auth login

# 2. Launch (creates fly.toml if not present; skip if you have one)
flyctl launch --no-deploy

# 3. Set secrets (stored in Fly's encrypted vault, not in fly.toml)
flyctl secrets set API_KEY=my-production-key

# 4. Deploy the app
flyctl deploy

# 5. Check status
flyctl status

# 6. Tail live logs
flyctl logs

# 7. For Ollama sidecar: deploy as a separate Fly Machine in same org
flyctl machine run ollama/ollama \
  --name entity-agent-ollama \
  --vm-memory 6144 \
  --env OLLAMA_HOST=0.0.0.0 \
  --port 11434/tcp:11434

# 8. Pull model into the Ollama machine
flyctl ssh console --app entity-agent-ollama \
  -C "ollama pull mistral:7b-instruct-q4_K_M"

Option 3: Railway

WHAT: Railway deploys from GitHub push; Ollama runs as a second Railway service in the same project
GOTCHA: Railway's free tier is 512 MB RAM — far too small for Mistral-7B. Use the Starter plan ($5/mo) with 8 GB RAM
# Deploy to Railway (CLI)
npm install -g @railway/cli
railway login

# 1. Initialize project linked to GitHub repo
railway init

# 2. Add Ollama as a second service in same project
#    (Railway UI: New Service → Docker Image → ollama/ollama)
#    Then set OLLAMA_HOST env var in Railway UI to the Ollama service's
#    internal URL (Railway provides this automatically)

# 3. Set secrets
railway variables set API_KEY=my-production-key

# 4. Deploy (Railway auto-detects Dockerfile)
railway up

# 5. View logs
railway logs --tail

# WHAT: Railway provides auto-TLS on *.railway.app domains
# No Caddy or cert management needed.
# Production Deployment Checklist

## TLS
- [ ] HTTPS only — HTTP redirects to HTTPS (Caddy / Fly / Railway handle this)
- [ ] TLS 1.2+ only — disable TLS 1.0 and 1.1

## Secrets
- [ ] API_KEY set via env (fly secrets / railway variables / .env on VPS)
- [ ] No API keys committed to git (check with: git log -p | grep API_KEY)
- [ ] .env file has chmod 600 on VPS

## Ollama
- [ ] OLLAMA_HOST=0.0.0.0 inside the Ollama container
- [ ] Model pulled and visible: curl http://ollama:11434/api/tags
- [ ] Port 11434 NOT exposed publicly (only internal network)

## Health Checks
- [ ] /health returns {"status":"ok","ollama":true} before routing traffic
- [ ] depends_on: condition: service_healthy in docker-compose.yml

## Graceful Shutdown
- [ ] stop_grace_period: 30s in docker-compose.yml (or Fly/Railway equivalent)
- [ ] SIGTERM handler in the app (uvicorn handles this by default)
The service is deployed. Now wire in the Prometheus metrics you built in M20 so you get request count, latency histograms, and active connections without any new dependencies.

Prometheus Metrics Integration

In M20 you built a MetricsCollector that tracks request count, latency histograms, and active connections. Exposing those metrics from the FastAPI service takes exactly two lines: mount the Prometheus ASGI app at /metrics.

WHAT: Expose /metrics endpoint for Prometheus scraping
WHY: Grafana dashboards from M20 connect to this endpoint; no new instrumentation code needed
GOTCHA: Restrict /metrics to internal network only in production — never expose raw metrics publicly
# src/main.py  (add these two lines to the existing FastAPI app)
# WHAT: Mount prometheus_client's ASGI metrics app at /metrics
# WHY: Zero new instrumentation; reuses all MetricsCollector state from M20
# GOTCHA: prometheus_client.make_asgi_app() must be mounted AFTER
#         all routes so it doesn't shadow your own /metrics route

from prometheus_client import make_asgi_app          # LINE 1
app.mount("/metrics", make_asgi_app())               # LINE 2

# That's it. Your MetricsCollector from M20 already increments the counters.
# Prometheus scrapes: curl http://localhost:8000/metrics
# Sample output:
#
# agent_requests_total{endpoint="/agent/run",status="200"} 142.0
# agent_request_duration_seconds_bucket{le="0.5",...} 118.0
# agent_active_connections 3.0
// src/app.js  (add these two lines to the existing Express app)
// WHAT: Expose /metrics for Prometheus using prom-client
// WHY: Reuses all metrics registered in MetricsCollector from M20
// GOTCHA: Must import prom-client AFTER all MetricsCollector imports
//         so the default registry captures all previously registered metrics

import { register } from 'prom-client';   // LINE 1

app.get('/metrics', async (req, res) => { // LINE 2 (two lines, counts as two)
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

// Sample output from: curl http://localhost:8000/metrics
// agent_requests_total{endpoint="/agent/run",status="200"} 142
// agent_request_duration_seconds_bucket{le="0.5"} 118
// agent_active_connections 3
Why It Matters: Metrics-to-Deploy Integration

Adding two lines to expose /metrics means your Grafana dashboards from M20 immediately show production traffic — request rates, p50/p95 latency, active connections, and error rates — without any new instrumentation work. When you add the job queue in a real deployment, you increment the same Prometheus counters from the worker process too, giving you a unified view of synchronous and async traffic in one dashboard.

Request Lifecycle — Client to Stream Response
💻
Client
POST /agent/stream
🪣
Rate Limiter
10 req/min / key
Validation
Pydantic / Zod
🤖
Agent Loop
ReAct + Ollama
📡
SSE Stream
thought/tool/result
📺
Client UI
renders steps live
Request lifecycle: Client → Rate Limiter (429 on breach) → Pydantic Validation (422 on error) → Agent Loop (ReAct + Ollama) → SSE Stream → Client UI.

Lab: Wrap Capstone C3 as a FastAPI Streaming Service

You built the Capstone C3 entity resolution agent in an earlier module. In this lab, you wrap it as a production FastAPI streaming service, containerize it with Docker Compose and an Ollama sidecar, and verify the end-to-end flow with curl.

🦎 Prerequisites

Working Capstone C3 agent code, Docker Desktop (or Docker Engine on Linux), and the mistral:7b-instruct-q4_K_M model either already pulled locally or a connection fast enough to pull ~4 GB. Estimated time: 40 minutes.

Create the FastAPI Application

Create src/main.py using the boilerplate from the FastAPI Setup section. Wire your Capstone C3 run_entity_resolution_agent() function into the /agent/run endpoint.

mkdir -p src && touch src/__init__.py src/main.py src/models.py
pip install fastapi uvicorn[standard] pydantic httpx
Expected: FastAPI imports without error
python -c "import fastapi; print(fastapi.__version__)" 0.111.0
Checkpoint 1

uvicorn src.main:app --reload starts without errors. curl http://localhost:8000/health returns {"status":"ok"} (ollama may be false until the sidecar is running — that's expected).

Convert the Agent Loop to an SSE Generator

Refactor run_entity_resolution_agent() into an async def stream_entity_resolution_agent() generator that yields step dictionaries. Add the /agent/stream endpoint from the Streaming section.

# In your agent.py: convert synchronous loop to async generator
async def stream_entity_resolution_agent(query, session_id=None, max_iterations=8, ollama_host="http://localhost:11434"):
    """Yields {type, content} dicts for each ReAct step."""
    for iteration in range(max_iterations):
        # ... your existing ReAct loop logic ...
        yield {"type": "thought", "iteration": iteration, "content": thought_text}
        # ... call tool ...
        yield {"type": "tool_call", "tool": tool_name, "summary": tool_result_summary}
        if is_final:
            yield {"type": "result", "content": final_answer}
            return
    yield {"type": "result", "content": "Max iterations reached", "truncated": True}
Checkpoint 2

The /agent/stream endpoint is registered. curl -N -X POST http://localhost:8000/agent/stream -H "Authorization: Bearer $API_KEY" -d '{"query":"test"}' should return a stream of event: thought\ndata: {...}\n\n lines even if Ollama is not yet running (it may return an error event — that's fine).

Test the Streaming Endpoint with curl

Set API_KEY and send a real entity resolution query. Watch the SSE events arrive in real-time.

export API_KEY=dev-key-1234

# The -N flag disables buffering so you see events as they arrive
curl -N -s -X POST http://localhost:8000/agent/stream \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"Is Acme Logistics LLC the same as ACME LOGISTICS L.L.C.?","max_iterations":5}'
Expected streaming output
event: thought data: {"type":"thought","iteration":0,"content":"I need to compare the two entity names for similarity..."} event: tool_call data: {"type":"tool_call","tool":"fuzzy_match_score","summary":"score=0.97 (near-identical)"} event: thought data: {"type":"thought","iteration":1,"content":"High fuzzy score. Checking UCC filings for both variants..."} event: tool_call data: {"type":"tool_call","tool":"search_filings_by_name","summary":"3 filings found for ACME LOGISTICS"} event: result data: {"type":"result","content":"MATCH — confidence 0.94. Both names resolve to the same legal entity."}
Checkpoint 3

You see at least one thought event within 3 seconds and a final result event. If you see an error event, check that Ollama is running: curl http://localhost:11434/api/tags.

Build the Docker Image

Write the multi-stage Dockerfile and docker-compose.yml from the Docker Deployment section. Build the image and start the stack.

# Build the image (multi-stage — runtime stage only)
docker build --target runtime -t entity-agent:dev .

# Verify image size (should be < 600 MB for Python, < 400 MB for Node)
docker images entity-agent:dev

# Start the full stack (api + ollama sidecar)
API_KEY=dev-key-1234 docker compose up -d

# Wait for both health checks to pass (about 30s for Ollama to init)
docker compose ps
NAME IMAGE STATUS entity-agent-api-1 entity-agent:dev Up 45 seconds (healthy) entity-agent-ollama-1 ollama/ollama:latest Up 60 seconds (healthy)
Checkpoint 4

Both services show (healthy) in docker compose ps. The api service took longer than ollama to reach healthy because it waited for ollama's HEALTHCHECK to pass first. This confirms depends_on: condition: service_healthy is working.

Verify the Dockerized Service End-to-End

Pull the Mistral model into the Ollama sidecar and run the full streaming test against the containerized stack.

# Pull the quantized model into the sidecar (one-time, ~4.1 GB)
docker compose exec ollama ollama pull mistral:7b-instruct-q4_K_M

# Confirm model is loaded
curl -s http://localhost:11434/api/tags | python -m json.tool

# Full end-to-end test through Docker (same command as Checkpoint 3)
curl -N -s -X POST http://localhost:8000/agent/stream \
  -H "Authorization: Bearer dev-key-1234" \
  -H "Content-Type: application/json" \
  -d '{"query":"Is Acme Logistics LLC the same as ACME LOGISTICS L.L.C.?"}'

# Verify /health sees ollama as healthy
curl -s http://localhost:8000/health | python -m json.tool

# Verify /metrics endpoint
curl -s http://localhost:8000/metrics | grep agent_requests
{"status": "ok", "ollama": true, "version": "1.0.0"} # metrics endpoint agent_requests_total{endpoint="/agent/stream",status="200"} 1.0 agent_request_duration_seconds_sum 8.432 agent_active_connections 0.0
Checkpoint 5 (Final)

The Dockerized service streams real agent steps powered by Mistral-7B-Q4 via the Ollama sidecar. The /health endpoint confirms Ollama is reachable. The /metrics endpoint shows the request count increment. You have a fully containerized, production-ready agent API running entirely on open-source components — no external API keys, no GPU, deployable anywhere Docker runs.

Knowledge Check

Test your understanding of API design, streaming, and deployment patterns.

1. An agent takes 45 seconds on average. A client using the synchronous POST /agent/run endpoint reports intermittent 504 errors after 30 seconds. What is the most appropriate fix?

A
Increase the Ollama timeout to 120 seconds
B
Switch the client to use the SSE streaming endpoint or the async job queue pattern
C
Add retry logic on the client with exponential backoff
D
Reduce max_iterations to 3 so the agent finishes in under 30 seconds

2. What HTTP status code should POST /agent/jobs return when it successfully enqueues a job but has not yet started processing?

A
200 OK — the request was successful
B
201 Created — a new resource was created
C
202 Accepted — request received but processing not yet complete
D
204 No Content — the server has nothing to return yet

3. You deploy the FastAPI + Ollama stack behind Nginx. Users report that the SSE streaming endpoint delivers all events at once at the end instead of progressively. What is the root cause?

A
The media_type in StreamingResponse is wrong
B
Nginx is buffering the response; the fix is to add X-Accel-Buffering: no to the response headers
C
The async generator is not using yield correctly
D
Python's GIL is preventing concurrent SSE writes

4. In the token bucket rate limiter, a client with a fresh bucket sends 11 requests in rapid succession. What happens to the 11th request?

A
It is queued and processed when a token becomes available
B
It is processed normally because the bucket refills instantly
C
It is rejected with 429 Too Many Requests and a Retry-After header
D
It triggers an HTTP 503 Service Unavailable

5. In the docker-compose.yml, the api service has depends_on: ollama: condition: service_healthy. What does this guarantee?

A
The api container starts only after the ollama container process has spawned
B
The api container starts only after the ollama container's HEALTHCHECK has passed at least once
C
The api container and ollama container start simultaneously
D
The api container will restart automatically if the ollama container becomes unhealthy

6. Why must OLLAMA_HOST=0.0.0.0 be set inside the Ollama container when using Docker Compose?

A
So Ollama can pull model weights from the internet on startup
B
Without it, Ollama listens only on 127.0.0.1 inside its own container, making it unreachable from the api container on the bridge network
C
So the Ollama container can accept connections from the public internet
D
To enable GPU passthrough inside the container
0/6