Module 21 of 30
API Design
& Deployment

Your agent works on your laptop. Now ship it. Six concepts cover how clients talk to it, how to package it, where to host it, how to scale it under load, how to stream tokens, and how to lock it down.

Track 7: Production Deployment ⏱ ~18 min read 21 / 30
Concept 1 of 6

How clients talk to your agent

An agent reply takes 5–45 seconds. The wire protocol you pick decides whether the user stares at a blank screen or watches tokens stream in. Three options exist: REST (one request, one full response), SSE (server pushes events over a long-lived HTTP connection), and WebSocket (full duplex; both sides can send anytime).

For 95% of agent traffic the right answer is SSE. It rides on plain HTTP, traverses every proxy and CDN, and matches the upstream Anthropic streaming API one-for-one. REST is for stateless side calls (health, feedback). WebSocket only earns its complexity when the client must send signals during the stream.

Concept 1 of 6

Letter vs. radio vs. phone call

BEFORE: You need updates from a friend across town. You could mail a letter, ask "anything new?" and wait days for the reply. That is REST polling — clean, simple, but every poll costs a stamp even when the answer is "nothing yet."

PAIN: If your friend is writing a long story, sending a new letter every five seconds wastes paper and clogs the mailbox. A live phone call (WebSocket) fixes the wait but ties up the line continuously, even during silence.

MAPPING: The sweet spot is a radio broadcast (SSE). Your friend speaks into the mic, you listen on a one-way channel, you hear each sentence as it lands, and the channel closes cleanly when the message ends. No polling waste, no idle phone line. That matches how an agent actually generates — one direction, in chunks, until done.

Concept 1 of 6

What an SSE stream looks like

  1. Client opens one HTTP requestPOST or GET with header Accept: text/event-stream. The connection stays open instead of closing after the response.
  2. Server responds with event-stream content typeContent-Type: text/event-stream. Now the channel is "open mic" until the server closes it.
  3. Server writes named events as they happenEach event has an optional event: name (token, tool_call, done) and a data: JSON payload. Lines flush immediately.
  4. Browser parses with EventSourceThe native EventSource API auto-reconnects on drop and dispatches each event to a JavaScript handler.
Concept 1 of 6

REST vs SSE vs WebSocket

# Question: which protocol for this endpoint?

IF endpoint is stateless side call:
  # /health, /feedback, /history
  USE REST  # request → full response → close

ELIF server pushes tokens, client only listens:
  # /chat with token-by-token output
  USE SSE   # default for 95% of agent calls

ELIF client must send mid-stream signals:
  # cancel, mid-run param tweak, live tool result
  USE WebSocket  # pay the complexity tax

ELSE:
  DEFAULT to SSE  # simpler than WS, streams better than REST

Closing the SSE connection is itself a "stop generating" signal — you almost never need WebSocket just for cancel.

Concept 1 of 6

Misconceptions

"WebSockets are always better — bidirectional wins."
More capability is not better. WebSockets need protocol upgrades that some proxies and CDNs strip, plus heartbeats, sticky sessions, and reconnection logic. SSE solves the 95% case with zero of that overhead.
"REST polling is fine if I poll often enough."
Polling every 200ms means 5 requests per second per user. At 100 concurrent users that is 500 RPS — mostly empty replies. SSE uses one open connection per user with zero wasted requests.

SSE is the default for agent streaming. Plain HTTP, native browser support via EventSource, matches Anthropic's streaming API exactly.

Reach for REST only on stateless side calls and for WebSocket only when the client must push data mid-stream.

Concept 2 of 6

Pack the agent into a portable box

Your laptop has Python 3.12 and libssl 3.0. The production VM has Python 3.10 and libssl 1.1. Without a container, "works on my machine" is a feature, not a bug. Docker fixes this by packaging your agent code, runtime, and every shared library into one immutable image — a frozen snapshot that runs identically on a Mac, in CI, and on any cloud.

Each image is built from a small declarative script (the Dockerfile). Each instruction adds a cached layer; if only your code changed, Docker reuses the dependency layer from yesterday and rebuilds in seconds. A running copy of the image is a container — an isolated process that shares the host kernel (so it boots in milliseconds, unlike a VM).

Concept 2 of 6

The shipping container revolution

BEFORE: Before standardized shipping containers in the 1950s, loading a cargo ship was chaos. Every crate had a different shape, fragile goods needed special crews, and dockworkers spent days hand-packing irregular cargo into ship holds.

PAIN: Moving cargo between a ship and a train meant unpacking and repacking the entire load. Software has the identical disease: your agent depends on a precise mix of language runtime, OS libraries, and config that exists only on your machine. Re-creating that on a server is the modern version of hand-packing crates.

MAPPING: Docker is the shipping container of software. You pack the agent, its Python interpreter, its dependencies, and its config into one standardized box. That box drops onto Cloud Run, Lambda, ECS, your laptop, or a coworker's CI runner without a single repack — exactly like a sealed shipping container fits any truck, ship, or train.

Concept 2 of 6

Source → image → running container

  1. Write a DockerfileA text file listing every step: pick a base image, copy dependency manifest, install, copy code, set the start command.
  2. Build the imageEach instruction becomes a cached layer. If only your code changed, the dependency layer is reused. 3-minute build becomes a 10-second rebuild.
  3. Push to a registryTag the image and push it to a registry (Artifact Registry, ECR, Docker Hub) so cloud platforms can pull it on deploy.
  4. Run as a containerThe cloud platform pulls the image and starts one or more isolated processes from it. Same image, many parallel containers — that is how autoscale works.
  5. Inject secrets at runtimeAPI keys live in env vars or a secrets manager — never inside the image. .dockerignore keeps .env files out of the build context.
Concept 2 of 6

An agent image, layer by layer

# WHAT: Pack an agent into a portable, layered image
# WHY:  Identical runtime on laptop, CI, and cloud

START FROM slim python base
  # 150 MB, not 900 MB; fewer CVEs to patch

COPY dependency manifest FIRST
RUN install dependencies
  # cached layer; rebuilds skip when code-only changes

COPY agent source code
CREATE non-root user, switch to it
  # exploit blast radius shrinks dramatically

DECLARE healthcheck endpoint
EXPOSE port 8080
SET start command RUN agent server

# GOTCHA: never bake secrets here. Even after RUN rm,
# the original layer still contains the file.
Concept 2 of 6

Misconceptions

"Containers are basically lightweight VMs."
No. A VM runs a full guest OS with its own kernel — gigabytes of RAM, minutes to boot. A container is just an isolated process sharing the host kernel — megabytes of RAM, milliseconds to start. That speed is what makes scale-to-zero possible.
"I deleted the .env file in a later step, so it's safe."
Layers are immutable. Deleting the file in step 8 hides it from the final filesystem but leaves it in the layer where it was added. docker history or docker save recovers it. Use .dockerignore to keep secrets out of the build context entirely.

Docker = portable box + layered cache + isolated process. Slim base, dependency layer first, non-root user, no secrets baked in.

The image you push from CI is byte-for-byte the image that runs in production — the "works on my machine" excuse retires for good.

Concept 3 of 6

Where the container runs

You have an image. Now you choose a host. Three categories cover almost every agent: serverless functions (Lambda) bill per invocation but cap timeouts at 15 minutes and limit streaming. Managed containers (Cloud Run, Azure Container Apps) auto-scale your image from zero to thousands, support 60-minute timeouts, and stream natively. VMs / Railway stay always-on with no cold start but cost the same whether traffic is 0 or 10,000 RPS.

For most agent APIs Cloud Run wins: streaming, scale-to-zero pricing, generous timeouts, one-line deploy. Lambda is right for short synchronous tasks. VMs make sense only for steady high-traffic workloads where the always-on cost is lower than serverless per-invocation pricing.

Concept 3 of 6

Renting, leasing, or buying a car

BEFORE: You need a vehicle for occasional trips. Renting by the hour (Lambda) means you only pay when driving — perfect for short, occasional drives. But every rental starts with paperwork (cold start) and you cannot keep the car past 15 minutes per trip.

PAIN: Most agent calls take 30–60 seconds of multi-step reasoning, sometimes much more. Lambda's 15-minute cap technically fits, but its limited streaming means tokens cannot reach the user the way they expect. Buying the car outright (a dedicated VM) lets you drive forever, but you pay even when it sits in the garage overnight.

MAPPING: Cloud Run is leasing. The car appears when you need it (auto-spin-up), can stay with you for up to 60 minutes per trip, and returns to the lot when you are done (scale-to-zero). It supports the open-window streaming agents need, and it costs nothing while idle. For 80% of agent products, this is the right vehicle.

Concept 3 of 6

From image to live URL

  1. Push image to a registryTag your image and upload it to Artifact Registry, ECR, or Docker Hub. The cloud platform will pull from here.
  2. Deploy with one commandCloud Run, App Runner, or Container Apps each take an image URL and a region, then return a live HTTPS URL with TLS terminated for you.
  3. Cold start the first instanceFirst request after idle wakes a container from scratch — pulls the image, boots the runtime, runs your start command. Users feel 1–5 seconds.
  4. Auto-scale on demandAs concurrent requests rise, the platform spins up more identical containers in parallel. Idle instances are killed within minutes; you stop paying.
  5. Roll back instantlyEach deploy creates a new revision. Traffic split lets you canary 5% to v2, watch metrics, then flip 100% — or rollback to v1 in one command.
Concept 3 of 6

Lambda vs Cloud Run vs Railway / VM

DimensionLambdaCloud RunRailway / VM
Max timeout15 min60 minunlimited
Streaming (SSE)limitednativenative
Scale to zeroyesyesno
Cold start0.5–3s1–5snone
Cost when idle$0$0$5–50/mo
Best forshort tasksmost agentsalways-on

Cloud Run is the sweet spot. For 10K agent calls/day at ~30s each, expect ~$15–30/month vs $50+/month for an always-on VM.

Concept 3 of 6

Misconceptions

"Serverless is always cheaper because you only pay for use."
Only for bursty, low-traffic workloads. At sustained high RPS 24/7, per-invocation pricing on Lambda or Cloud Run can exceed a reserved VM. Run the math against actual traffic before assuming.
"Cold starts only happen once, so they don't matter."
Cold starts happen every time a new instance spins up — on every traffic spike, after every quiet period, on every new region. Users feel 2–5 seconds of dead air. For chat UX that is a real problem; mitigate with min-instances or warm-up requests.

Default: Cloud Run (or equivalent managed container). Native streaming, 60-minute timeouts, scale-to-zero, one-line deploys.

Use Lambda only for short stateless tasks. Use a VM only when sustained traffic makes the always-on cost cheaper than per-invocation pricing.

Concept 4 of 6

Scale on concurrency, not CPU

Agent handlers spend 95% of their time waiting — for Claude's API, for tool calls, for database queries. CPU usage stays at 5–10% even when the instance is fully saturated with in-flight requests. If you scale on CPU like a normal web app, you will never add instances and your queue will quietly grow forever.

The right signal is concurrent requests per instance. Cloud Run's --concurrency=10 says: each instance handles at most 10 simultaneous requests; the 11th triggers a new instance. Add a queue in front to absorb bursts, plus exponential backoff and a circuit breaker on the upstream Claude API to handle 429s gracefully.

Concept 4 of 6

The restaurant with one kitchen

BEFORE: A restaurant. Waiters are your server instances — each can carry a few tables at once. Quiet Monday: one waiter is plenty. Busy Saturday: ten.

PAIN: But the kitchen (Anthropic API) has a fixed cook rate. If all ten waiters slam orders in at once, the kitchen sends back "we're overwhelmed" tickets — that is HTTP 429. Hiring more waiters does not help if the kitchen is the bottleneck.

MAPPING: The fix is a reservation system (a task queue). Orders enter the queue and leave at a rate the kitchen can match. When the kitchen flashes "slow down," the queue backs off automatically (exponential backoff). And a circuit breaker stops sending entirely if the kitchen has truly fallen over — better to pause than flood it with retries.

Concept 4 of 6

Queue + worker + backoff + breaker

  1. API server enqueues the taskAccept the HTTP request, push a task object onto Redis / SQS / Cloud Tasks, return a job_id. The HTTP request closes in milliseconds.
  2. Workers pull at a controlled rateA pool of worker processes pulls tasks one at a time and runs the agent. Worker count autoscales on queue depth.
  3. Honor retry-after on 429If Claude returns 429 with a retry-after header, sleep that long. With no header, exponential backoff: 1s, 2s, 4s, 8s — each with random jitter to avoid thundering herd.
  4. Trip the circuit breaker after repeated failures5 consecutive 429s? Stop sending for 30 seconds. This gives the upstream room to recover instead of drowning it in retries.
  5. Long-running agent? Use 202 + poll, or SSEAnything past 60 seconds: return 202 Accepted with a job_id and let the client poll, or stream incremental progress over SSE so proxies do not idle the connection out.
Concept 4 of 6

Queue producer + resilient worker

# WHAT: Decouple accept from execute
# WHY:  Burst absorption + graceful 429 handling

DEFINE handler(request):
  job_id = ENQUEUE task(request.payload)
  RETURN 202, { job_id }

DEFINE worker_loop():
  LOOP:
    task = PULL from queue
    IF circuit_breaker.is_open:
      REQUEUE task; SLEEP 30s; CONTINUE

    delay = 1
    FOR attempt in 1..5:
      resp = CALL claude(task)
      IF resp.ok: RETURN resp
      IF resp.status == 429:
        SLEEP resp.retry_after OR delay + jitter
        delay = delay * 2
      ELSE: RAISE
    circuit_breaker.RECORD_failure()
Concept 4 of 6

Misconceptions

"Scale on CPU usage like every other web app."
Agent handlers are I/O-bound, not CPU-bound. CPU stays at 5–10% while the instance is fully saturated. Scale on concurrent requests per instance instead, or your queue will balloon while no instances ever spin up.
"Just retry immediately on 429 — the limit will clear soon."
Immediate retries pile on the same overload that triggered the 429 in the first place. Always honor retry-after, fall back to exponential backoff with jitter, and trip a circuit breaker after repeated failures.

Queue + concurrency-based autoscale + backoff + breaker. Four building blocks survive every traffic spike and upstream hiccup.

For runs longer than 60 seconds, decouple accept from deliver: 202 + job_id with polling, or SSE with incremental progress events.

Concept 5 of 6

Tokens as they arrive

An agent reply is built one token at a time over 5–30 seconds. Without streaming, the user stares at a blank screen for that whole window, then everything dumps at once. With streaming, the same reply feels alive — tokens flow in, tool calls show as progress chips, and abandonment drops sharply.

The Anthropic SDK gives this for one extra parameter: stream=True in Python or .stream() in Node. You iterate over typed delta events (content_block_delta for text, content_block_start for tool_use, message_stop at the end) and forward each one to the client as a typed SSE event.

Concept 5 of 6

The waiter who keeps you posted

BEFORE: You order food and the waiter walks away. Five minutes pass with zero updates. Ten minutes. You start scanning the room wondering if they forgot.

PAIN: Agents take 5–30 seconds. Without progressive output, the user assumes the request died and hits refresh — doubling your API spend and possibly stranding the original generation. Or they abandon entirely. Both outcomes cost real money.

MAPPING: Streaming is the waiter saying "appetizer is on the way" then "entrée is plating now." Each delta event is a small reassurance: a token, a tool-call progress chip, a step-complete badge. The user stays engaged and trusts the system because the system is visibly working.

Concept 5 of 6

Claude delta → SSE event → UI update

  1. Open Claude in stream modeSet stream=True (Python) or call .stream() (Node). Instead of one final response, you get an async iterator of typed delta events.
  2. Translate each delta to a typed SSE eventMap content_block_deltatoken, content_block_start with tool_use → tool_start, message_stopdone. Clients render based on event type.
  3. Set anti-buffering headersCache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no. Without these, nginx or Cloudflare buffers the entire stream and ruins the UX.
  4. Client consumes via EventSourceThe browser's native EventSource parses each event and dispatches it to a handler. Auto-reconnect on drop is built in.
  5. Map tool names to friendly labelsShow "Searching filings..." instead of search_filings. The black-box wait becomes a transparent workflow.
Concept 5 of 6

SSE chat endpoint

# WHAT: Forward Claude deltas as typed SSE events
# WHY:  Token-by-token UX instead of a 15-second blank screen

DEFINE stream_handler(request):
  SET response.headers:
    Content-Type = "text/event-stream"
    Cache-Control = "no-cache"
    X-Accel-Buffering = "no"

  FOR EACH event IN claude.stream(request.message):
    IF event.type == "content_block_delta":
      YIELD sse("token", { text: event.delta.text })

    ELIF event.type == "content_block_start" AND tool:
      YIELD sse("tool_start", { name: event.tool_name })
      result = RUN tool(event.input)
      YIELD sse("tool_result", { data: result })

    ELIF event.type == "message_stop":
      YIELD sse("done", { usage: event.usage })

# GOTCHA: cap each run at 5 min via a timeout wrapper;
# on timeout, emit a partial event with truncated:true.
Concept 5 of 6

Misconceptions

"Streaming works out of the box once I set stream=True."
Not behind nginx, Cloudflare, or many corporate proxies. Without Cache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no, intermediaries buffer the entire stream and deliver it as one chunk after 30 seconds.
"Forward every delta event to the UI as-is."
Map deltas to typed client events: token, tool_start, tool_result, done, error. Untyped streams force the UI to parse Claude internals; typed events let the UI render appropriate widgets per event type.

Streaming = stream=True + typed SSE events + anti-buffering headers + a friendly tool-name map.

The user sees the agent thinking in real time, abandonment drops, and accidental refresh-driven duplicate spend disappears.

Concept 6 of 6

Three gates: authenticate, authorize, throttle

An open agent endpoint is an open door to your LLM budget. One leaked URL and a bot can run up thousands of dollars in API spend, or worse, trigger destructive tool actions. Defense is three sequential gates: authenticate (prove who you are — API key or JWT), authorize (this role is allowed to call this tool), throttle (per-user rate or token budget).

The critical rule: enforce tool permissions in middleware, never in the agent's prompt. Prompt-level rules can be bypassed with prompt injection. Middleware sits in the request path where the model has no influence over the decision.

Concept 6 of 6

The hospital with three checkpoints

BEFORE: A hospital where every door is unlocked and anyone can wander into the pharmacy or the surgical suite. No badge readers, no logs, no friction.

PAIN: Disaster. Anyone can grab controlled substances or operate equipment they have no training for. The same logic applies online: an unauthenticated agent API is the open pharmacy — unbounded LLM spend, leaked customer data, destructive tool calls all running on your bill.

MAPPING: Authentication is the badge reader at the front door (does this badge belong to a real person?). Authorization is the access list per room (does this badge unlock this door?). Throttling is the supply cabinet limit (you can take five vials per hour, not five hundred). Three checkpoints, each enforced in the building's wiring — not via "please be polite" signs taped to the wall.

Concept 6 of 6

The three-layer defense in flight

  1. Extract the credentialRead the Authorization: Bearer <token> header. Missing? Return 401 immediately and skip every later step.
  2. Verify the tokenFor API keys: look up in a table. For JWT: verify the signature with the issuer's public key, check expiry. Invalid? 401.
  3. Look up the user's role and permissionsFrom the verified token (JWT claims) or a side database. Attach to the request object so downstream middleware can read it.
  4. Authorize the requested toolCheck role → allowed tools map. Analyst can call search_filings; only admin can call delete_filing. Forbidden? 403.
  5. Check the per-user budgetSliding window in Redis: 20 requests per minute, or N tokens per day. Over limit? 429 with Retry-After.
Concept 6 of 6

Auth middleware chain

# WHAT: Three gates before the agent runs anything
# WHY:  Cap LLM spend, block destructive tool calls

DEFINE authenticate(request):
  token = EXTRACT Bearer from headers
  IF missing: RETURN 401
  user = VERIFY(token)   # signature + expiry
  IF invalid: RETURN 401
  request.user = user
  NEXT

DEFINE authorize_tool(tool_name, user):
  allowed = ROLE_TOOL_MAP[user.role]
  # analyst: [search, report]; admin: [+delete]
  IF tool_name NOT IN allowed:
    LOG blocked(tool_name, user.id, user.role)
    RETURN 403
  NEXT

DEFINE throttle(user):
  used = redis.COUNT(user.id, window="60s")
  IF used >= 20:
    RETURN 429 + Retry-After header
  redis.INCR(user.id)
  NEXT
Concept 6 of 6

Misconceptions

"I'll tell the agent in the system prompt which tools each role can use."
Prompt rules can be bypassed with prompt injection. A user input that says "ignore previous restrictions" may convince the model to call delete_filing. Tool permissions belong in middleware, where the model cannot influence the check.
"IP-based rate limiting is enough."
A single corporate IP can have hundreds of legitimate users; a bad actor rotates through cheap proxies. Rate limit by authenticated user ID instead, with sliding-window counters in Redis. For expensive tool calls, consider a daily token budget per user.

Three gates in middleware: authenticate → authorize → throttle. 401 / 403 / 429 are the three failure modes.

Tool permissions live in middleware, not in the prompt. That single rule blocks the most dangerous prompt-injection attack class for agent APIs.

One question per concept

Tap a card to reveal the answer.

Open the full module on desktop

The desktop version walks you through a complete production agent API: FastAPI server with SSE, multi-stage Dockerfile, JWT auth middleware, queue-based scaling, and a Cloud Run deploy — in Python and Node.js.

Open M21 on Desktop → Full code · SSE · Dockerfile · Cloud Run lab · ~75 min

Module 21 of 30 · Track 7: Production Deployment