CCD-F.1 · CCDVF-D2 + CCDVF-D6 + CCDVF-D5 · Process

Production Claude Chat Application.

This is the app most developers build first: a chat UI backed by Claude, with responses that stream in word by word instead of appearing all at once after a long wait. It exists because a 6-second pause before any text appears feels broken to a user, while the same 6 seconds spent watching words stream in feels fast. The hard part isn't calling the API, it's everything around the call: keeping the conversation history somewhere durable, knowing when the context window is about to overflow, and making sure a client disconnect mid-stream doesn't silently lose the assistant's answer.

20 min build·4 components·5 concepts

A streaming chat backend on the Messages API. The server owns conversation history (the API is stateless), renders content_block_delta events to the client as they arrive, appends the full response.content (not just the text) back onto history so tool/thinking blocks survive, and manages the context window with token counting plus compaction rather than an unbounded array. Prompt caching on the system prompt cuts the repeated-context cost that dominates long conversations.

D2 - heaviest domain
SourceDeveloper-cert production pattern
What do the colours mean?
Green
Official Anthropic doc or API contract
Yellow
Partial doc / inferred
Orange
Community-derived
Red
Disputed / changes frequently
Stack
Python or TypeScript SDK - session store (DB or Redis)
Needs
Messages API basics - stop_reason - SSE
Exam
CCD-F D2 (33% - the heaviest domain). 33.1% CCDVF-D2 · 11.0% CCDVF-D6 · 16.8% CCDVF-D5.
Loop the mascot - painterly hero illustration for the Production Claude Chat Application scenario.
End-to-end flowCCD-F D2 (33% - the heaviest domain)
01 · Problem framing

The problem

What the customer needs

  1. See the response start appearing within ~200ms, not after the full generation finishes.
  2. Never lose an in-progress answer when the browser tab closes or the network blips.
  3. Keep a 40-turn conversation coherent without the context window silently overflowing.

Why naive approaches fail

  1. Client-side-only history means refreshing the page loses the whole conversation (the Messages API is stateless - nothing is stored server-side unless you store it).
  2. Appending only response.text back onto history drops tool_use and thinking blocks, corrupting the next turn's context.
  3. An unbounded messages array eventually exceeds the context window and every request starts failing at once instead of degrading gracefully.
Definition of done
  • Time-to-first-token < 300ms on a warm cache
  • Zero conversations lost on client disconnect (server-side persistence, not client memory)
  • Context budget enforced before the 1M-token ceiling, with graceful compaction
  • Full response.content persisted per turn, including non-text blocks
02 · Architecture

The system

03 · Component detail

What each part does

4 components, each owns a concept. Click any card to drill into the underlying primitive.

Stream Handler

SSE consumer - UI renderer

Opens client.messages.stream(), listens for text deltas, and writes them to the client connection as they arrive instead of buffering the whole response.

Configuration

client.messages.stream({model, max_tokens, system, messages}) with a text-delta listener; call .finalMessage() once to get the complete Message for persistence.

Concept: streaming

Session Store

server-owned history

Persists the full message array per session_id in a database. The API is stateless - every request resends the whole history, so this store is the only source of truth.

Configuration

sessions[session_id].messages.push({role: 'assistant', content: response.content}) - the full content array, never just the extracted text.

Concept: session-state

Context Budget Manager

count_tokens + compaction

Calls messages.count_tokens() before each turn against the live model. When the count nears the working budget, trims oldest turns or engages compaction rather than sending an oversized request.

Configuration

if count_tokens(messages).input_tokens > BUDGET: trim oldest non-system turns, or (beta) enable context_management compaction with beta compact-2026-01-12.

Concept: context-window

Cache Breakpoint

system prompt - ephemeral

Marks the last block of the (stable, non-interpolated) system prompt with cache_control so repeated turns in the same conversation read from cache instead of reprocessing the full prefix.

Configuration

system: [{type: "text", text: SYSTEM_PROMPT, cache_control: {type: "ephemeral"}}] - SYSTEM_PROMPT never has a timestamp or session id interpolated into it.

Concept: prompt-caching
04 · One concrete run

Data flow

05 · Build it

6 steps to production

01

Open a stream and render deltas

Use the SDK's stream() helper rather than a manual SSE parser. Only text_delta events touch the UI; other event types drive server-side bookkeeping.

Open a stream and render deltas
client = Anthropic()

def stream_turn(messages, system_prompt):
    with client.messages.stream(
        model="claude-sonnet-5",
        max_tokens=4096,
        system=[{"type": "text", "text": system_prompt,
                 "cache_control": {"type": "ephemeral"}}],
        messages=messages,
    ) as stream:
        for text in stream.text_stream:
            yield text  # forward to the client connection
        return stream.get_final_message()
↪ Concept: streaming
02

Persist the full content array, not the text

The final assistant turn must carry every content block, including thinking or tool_use blocks, or the next request loses structure the API needs to stay coherent.

Persist the full content array, not the text
def persist_turn(session_id: str, user_text: str, final_message):
    store.append(session_id, {"role": "user", "content": user_text})
    store.append(session_id, {"role": "assistant", "content": final_message.content})
    # NOT final_message.content[0].text - that drops non-text blocks
↪ Concept: session-state
03

Budget the context window before every turn

Call count_tokens against the model you're about to use (token counts are model-specific) and trim or compact before the request, not after it fails.

Budget the context window before every turn
def enforce_budget(messages, system_prompt, model="claude-sonnet-5", budget=180_000):
    counted = client.messages.count_tokens(
        model=model, system=system_prompt, messages=messages,
    )
    while counted.input_tokens > budget and len(messages) > 2:
        messages = messages[2:]  # drop oldest user/assistant pair
        counted = client.messages.count_tokens(
            model=model, system=system_prompt, messages=messages,
        )
    return messages
↪ Concept: context-window
04

Branch on stop_reason after the stream ends

The stream's final message carries the same stop_reason contract as a non-streaming call. Handle max_tokens and refusal explicitly instead of assuming every stream ends cleanly.

Branch on stop_reason after the stream ends
final = stream.get_final_message()
if final.stop_reason == "max_tokens":
    mark_truncated(session_id)
elif final.stop_reason == "refusal":
    log_refusal(session_id, final.stop_details)
elif final.stop_reason == "end_turn":
    persist_turn(session_id, user_text, final)
↪ Concept: streaming
05

Inject runtime context without breaking the cache

On models that support it, append a role: 'system' message to the array for mid-conversation operator context instead of rewriting the top-level system prompt, which would invalidate the cached prefix on every turn.

Inject runtime context without breaking the cache
def inject_runtime_context(messages, note: str):
    # Claude Opus 4.8 only, no beta header. Must follow a user turn.
    messages.append({"role": "system", "content": note})
    return messages
↪ Concept: prompt-caching
06

Handle client disconnect without losing the turn

The Messages API stream itself has no server-side replay. Keep the request running server-side even if the client connection drops, and persist on completion regardless of whether anyone was still listening.

Handle client disconnect without losing the turn
async def run_turn_resilient(session_id, user_text):
    messages = load_history(session_id) + [{"role": "user", "content": user_text}]
    try:
        final = await run_stream(messages)  # keeps running server-side
    finally:
        if final:
            persist_turn(session_id, user_text, final)  # persists even if client left
↪ Concept: session-state
06 · Configuration decisions

The four decisions

DecisionRight answerWrong answerWhy
Where conversation history livesserver-side session store, keyed by session_idclient-side only (localStorage/component state)The Messages API is stateless. Client-only history loses everything on refresh and can't be trusted for gating or audit.
What gets persisted per turnthe full response.content arrayjust the extracted text stringDropping tool_use/thinking blocks corrupts the structure the next request needs, even in a text-only chat app that later adds tools.
Context overflow handlingcount_tokens before the request, trim/compact proactivelylet the request fail, then handle the errorA failed request after the user has waited is a worse experience than a quiet, proactive trim.
Default model tier for chat trafficclaude-sonnet-5claude-opus-4-8 for every messageSonnet 5 is the cost/quality default for conversational traffic; reserve Opus for a router to escalate into (Scenario CCD-F.4), not the default path.
07 · Failure modes

Where it breaks

4 failure pairs. Each maps to an exam-style question - the naive move on the left, the disciplined fix on the right.

Content truncation on persist

Server stores finalMessage.content[0].text as the assistant turn.

CCDF-01
✅ Fix

Store the full content array. Non-text blocks are silently dropped otherwise, breaking later turns.

Cache-breaking interpolation

System prompt string includes f"Current time: {now()}" at the top.

CCDF-02
✅ Fix

Keep the system prompt byte-identical across requests. Inject dynamic context via a role: 'system' message instead.

No proactive context budget

Messages array grows unbounded until a request 400s on context length.

CCDF-03
✅ Fix

Call count_tokens before every request and trim or compact before it fails, not after.

Streaming with no persistence path

Assistant text is only ever rendered to the open connection, never saved.

CCDF-04
✅ Fix

Always call .finalMessage() / get_final_message() and persist it, independent of whether the client is still connected.

08 · Budget

Cost & latency

Per-turn tokens (typical)
~1,800 input (cached) - 600 output

System prompt + tool defs read from cache after turn 1; only new user text + growing tail cost full price.

Per-turn cost (Sonnet 5)
~$0.006

At $2/$10 intro per MTok with ~70% cache-read rate on the stable prefix.

Time-to-first-token
~200-300ms

Streaming avoids waiting for the full generation; first token arrives as soon as the model emits it.

09 · Ship gates

Ship checklist

Two passes. Build-time gates verify the code; run-time gates verify the system in production.

Build-time

  1. Server-side session store keyed by session_idsession-state
  2. Stream text deltas to the client; persist full content array on completionstreaming
  3. count_tokens budget check before every requestcontext-window
  4. System prompt is byte-stable; cache_control on its last blockprompt-caching
  5. stop_reason branch covers end_turn, max_tokens, and refusal
  6. Default model is Sonnet 5, not Opus, for standard chat traffic
  7. Client disconnect does not abort server-side persistence

Run-time

  • Load test streaming under concurrent connections
  • Verify persistence completes even when the client disconnects mid-stream
  • Alert on time-to-first-token p95 exceeding target
  • Alert on cache_read_input_tokens dropping to zero (silent cache invalidation)
  • Context-budget trim logic covered by a test with an oversized synthetic history
  • stop_reason handling covers refusal, not just end_turn/max_tokens
10 · Question patterns

Five exam-pattern questions

Your chat app persists `response.content[0].text` as the assistant turn. After adding a tool, multi-turn conversations start behaving inconsistently. Why?
Persisting only the text block drops any tool_use (or thinking) blocks that were also in the response. Persist the full `content` array every time, regardless of whether the current turn used tools.
Users report the assistant sometimes 'restarts' mid-explanation on long conversations. What's the likely cause and fix?
The messages array is growing unbounded and hitting the context window, silently changing what the model can see. Add a proactive count_tokens budget check and trim or compact before the request, not after a failure.
You interpolate the current session ID into the system prompt for logging. Cache hit rate is near zero. What's wrong?
Prompt caching is a byte-exact prefix match. A per-session value in the system prompt makes every request's prefix unique. Move that value out of the cached system prompt and into a later message, or use a role: 'system' mid-conversation message.
A user's browser tab closes mid-response. Should the server abort the in-flight request?
No. Keep the request running server-side and persist the final message on completion regardless of client connection state. The Messages API has no server-side replay of a dropped stream, so losing the in-flight request also loses the turn.
11 · FAQ

Frequently asked

Do I need a database, or can I keep history in memory?
Use a database (or Redis with persistence) for anything beyond a local demo. In-memory history is lost on process restart and doesn't scale past one server instance.
Should I stream on every request?
Stream whenever max_tokens is large or the user is waiting live for the response - which is essentially always true for a chat UI. Non-streaming is fine for short, backend-only calls with small max_tokens.
How do I know when to trim history versus use compaction?
Trimming (dropping oldest turns) is simple and works for most chat apps. The beta compaction feature (compact-2026-01-12) is worth adopting when you need the model to retain awareness of dropped history rather than losing it outright - useful for long support or coaching sessions.
What model should a general-purpose chat app default to?
Claude Sonnet 5 for the vast majority of conversational traffic. It is the best cost/quality balance in the current lineup; reserve Opus 4.8 for a router-driven escalation path (see the model-selection scenario), not as the default.
Is it safe to cache the entire conversation, not just the system prompt?
The message array is not typically cached because it grows every turn and each growth invalidates a naive full-array cache marker. Cache the stable prefix (system + tool defs); let the growing message tail be uncached, or use auto top-level caching which places the marker on the last cacheable block.
Last reviewed: 2026-07-12·Refresh cadence: 90 days
CCD-F.1 · CCDVF-D2 · Applications & Integration

Production Claude Chat Application, complete.

You've covered the full ten-section breakdown for this primitive, definition, mechanics, code, false positives, comparison, decision tree, exam patterns, and FAQ. One technical primitive down on the path to CCA-F.

More platforms →