Claude Academy
Sign in

Vault / course/courses/building-with-the-claude-api.md

updated 2026-06-25

Course: Building with the Claude API

Mirrors: Anthropic Academy — Building with the Claude API · https://anthropic.skilljar.com/building-with-the-claude-api Audience: Developers. You can read Python and send an HTTP request. · Time: ~8 hrs across 5 sittings + projects Prereqs: An Anthropic Console account, an API key in ANTHROPIC_API_KEY, Python 3.9+ and pip install anthropic. Finish claude-101 first. · Backing notes: messages-api, system-prompts, tool-use, streaming, structured-output, prompt-caching, extended-thinking, vision, batch-api, multi-turn-conversations Project: p05-first-api-call (then p06-tool-use-weather-agent, p07-structured-extraction, p08-prompt-caching-and-rag)

This is the flagship developer course — the longest in the catalog. By the end you can build a production-grade application on the Claude API: send and parse Messages requests, stream responses, run a tool-use agent loop, force structured output, cache expensive context, use vision and extended thinking, and run high-volume work through the Batch API with real error handling.

Learning objectives

After this course you can:

  • Construct a messages.create request and parse every field of the response, including stop_reason and usage.
  • Manage multi-turn conversations yourself, knowing the API is stateless.
  • Pick the right model tier for a task on cost/latency/capability grounds.
  • Stream responses over SSE and handle each event type, including tool-use and thinking deltas.
  • Implement the full tool-use loop with tool_choice, parallel tools, and tool_result blocks.
  • Force reliable JSON out of Claude with a forced tool.
  • Cut cost and latency with prompt caching; reason harder with extended thinking; read images and PDFs.
  • Run thousands of requests through the Batch API and handle errors, retries, and rate limits like production.

Module 1 — The Messages API

🎞 Frame 1 · Your first request · ⏱ ~3 min

🎬 Scene — A terminal. Eight lines of Python, a key in the environment, and a streamed paragraph of Claude's reply appears.

🧠 Concept — Everything on the Claude API flows through one endpoint: POST /v1/messages. The SDK wraps it as client.messages.create(...). (Deeper: messages-api.)

🖼 On screen

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    system="You are a concise assistant.",
    messages=[
        {"role": "user", "content": "Explain prompt caching in two sentences."}
    ],
)
print(resp.content[0].text)

Checkpoint — Which three fields are required on every request? (model, max_tokens, messages.)

🎞 Frame 2 · Anatomy of the request · ⏱ ~3 min

🎬 Scene — Each request field is highlighted in turn as a side panel explains its job.

🧠 Concept — A request is a small, well-defined object. Know what each field controls and you control the model.

🖼 On screen

FieldPurpose
modelPinned model ID. Always pin — never float to "latest".
max_tokensHard cap on completion length. Required.
systemSystem prompt: string or array of content blocks. Persona + rules.
messagesAlternating user/assistant turns. First must be user.
tools / tool_choiceTool schemas and how Claude may use them.
temperature0–1. Lower = more deterministic.
stop_sequencesStrings that end generation early.
thinking{type: "enabled", budget_tokens: N} for extended thinking.
streamtrue for SSE streaming.

⚠️ Gotchamax_tokens caps output, not the whole context. Setting it too low truncates the answer and you get stop_reason: "max_tokens".

Checkpoint — What's the difference between stop_sequences and max_tokens ending a response?

🎞 Frame 3 · Content blocks, not strings · ⏱ ~3 min

🎬 Scene — A message's content zooms in from a plain string into a list of typed blocks.

🧠 Conceptcontent can be a plain string or a list of typed content blocks. Blocks are how you mix text, images, documents, tool calls, and tool results in one turn.

🖼 On screen

# These two user turns are equivalent:
{"role": "user", "content": "Hello"}
{"role": "user", "content": [{"type": "text", "text": "Hello"}]}

# Block types you'll meet:
# text · image · document · tool_use · tool_result · thinking

The response content is always a list of blocks:

{
  "content": [{"type": "text", "text": "..."}],
  "stop_reason": "end_turn",
  "usage": {"input_tokens": 123, "output_tokens": 45}
}

Checkpoint — Why must you read resp.content[0].text and not just resp.content? (Content is a list of blocks; you select the block and field.)

🎞 Frame 4 · stop_reason and usage · ⏱ ~3 min

🎬 Scene — A response object expands; stop_reason and usage glow.

🧠 Conceptstop_reason tells you why generation ended — it's the control signal for agent loops. usage tells you what you paid.

🖼 On screen

stop_reason values:
  end_turn       → Claude finished normally.
  max_tokens     → hit your cap; output is truncated.
  stop_sequence  → hit one of your stop strings.
  tool_use       → Claude wants you to run a tool, then call back.
  pause_turn     → a long-running server tool paused; resend to continue.

usage:
  {input_tokens, output_tokens,
   cache_creation_input_tokens, cache_read_input_tokens}

⚠️ Gotcha — Never assume end_turn. In a tool-using app, branching on stop_reason is the whole loop.

Checkpoint — Your app gets stop_reason: "tool_use". What must happen next? (Run the tool, append a tool_result, call the API again.)

🎞 Frame 5 · The API is stateless · ⏱ ~3 min

🎬 Scene — Two requests fired a minute apart; the server visibly "forgets" the first when the second arrives.

🧠 Concept — The Messages API is stateless. The server keeps no memory between requests. You own conversation state and must resend the full history every turn.

🖼 On screen

messages = []

def turn(user_text):
    messages.append({"role": "user", "content": user_text})
    resp = client.messages.create(
        model="claude-sonnet-4-6", max_tokens=1024, messages=messages,
    )
    messages.append({"role": "assistant", "content": resp.content})
    return resp.content[0].text

turn("My name is Chris.")
turn("What's my name?")   # works ONLY because we resent history

🔗 Deeper: multi-turn-conversations. This statelessness is why prompt-caching exists — to make replaying long histories cheap.

Checkpoint — Why does appending resp.content (not just its text) to messages matter for tool use? (Tool-use blocks must be preserved for the next turn to reference them.)

🎞 Frame 6 · Multi-turn and the user/assistant rhythm · ⏱ ~2 min

🎬 Scene — A conversation array fills with alternating roles.

🧠 Conceptmessages must alternate userassistantuser… and start with user. The system prompt sits outside the array. (Deeper: system-prompts.)

🖼 On screen

system="You are a terse SQL reviewer. Flag only real bugs."
messages=[
    {"role": "user", "content": "SELECT * FROM users WHERE id = id"},
    {"role": "assistant", "content": "`id = id` is always true..."},
    {"role": "user", "content": "Fix it."},
]

⚠️ Gotcha — Two consecutive turns of the same role is an API error. Merge them or interleave.

Checkpoint — Where does the system prompt go — in messages or its own field? (Its own system field.)

🎞 Frame 7 · Choosing a model · ⏱ ~3 min

🎬 Scene — Three model IDs on a slider from "cheapest/fastest" to "smartest/slowest".

🧠 Concept — Match the model to the task. Don't reflexively pick the biggest. (Deeper: model-family.)

🖼 On screen

TierUse it forTrade-off
OpusHardest reasoning, agents, deep analysisSmartest, slowest, priciest
SonnetMost production work — the defaultBalanced
HaikuHigh-volume classification, extraction, low latencyFastest, cheapest

A practical move: prototype on Sonnet, then try to drop to Haiku for the high-volume path and only escalate to Opus where Sonnet visibly struggles.

Checkpoint — You must classify 100k tickets nightly. Which tier and which API? (Haiku + Batch API.)


Module 2 — Streaming

🎞 Frame 8 · Why stream · ⏱ ~2 min

🎬 Scene — Two UIs side by side: one spins for 6 seconds, the other starts printing words in 300ms.

🧠 Concept — Streaming sends incremental deltas over Server-Sent Events. Use it anywhere time-to-first-token matters — chat UIs, anything a human watches. (Deeper: streaming.)

🖼 On screen

with client.messages.stream(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Tell me a story."}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)

final = stream.get_final_message()  # full Message after the stream ends

Checkpoint — What does stream.get_final_message() give you that the text loop doesn't? (The assembled Message with stop_reason and usage.)

🎞 Frame 9 · The event types · ⏱ ~3 min

🎬 Scene — An SSE log scrolls; each event type is labelled.

🧠 Concept — A stream is a sequence of typed events bracketing each content block. You rarely parse them by hand (the SDK helps), but you must know them for custom transports.

🖼 On screen

message_start                         → message metadata
  content_block_start
  content_block_delta (× many)        → the actual increments
  content_block_stop
message_delta                         → top-level updates; stop_reason arrives HERE
message_stop                          → done
ping                                  → keepalive
error                                 → terminal

delta sub-types:
  text_delta        → text
  input_json_delta  → partial tool_use JSON (a string you concatenate)
  thinking_delta    → extended-thinking text
  signature_delta   → thinking signature (redaction-proofing)

⚠️ Gotchastop_reason arrives on message_delta, not message_stop. Read it there.

Checkpoint — A tool_use block streams as input_json_deltas. When can you safely parse its JSON? (Only after content_block_stop for that block.)

🎞 Frame 10 · When NOT to stream · ⏱ ~2 min

🎬 Scene — A batch job and a tool loop are crossed off the "stream" list.

🧠 Concept — Streaming buys perceived latency for a human. It buys nothing for machines.

🖼 On screen

Skip streaming when:
  • Offline/bulk work        → use the Batch API.
  • Tool-use loops           → you need the whole turn before deciding next step.
  • Latency is tool-bound    → generation isn't the bottleneck.

Checkpoint — Name one case where streaming adds complexity with no payoff. (Batch classification — no human is watching.)


Module 3 — Tool use & structured output

🎞 Frame 11 · The tool-use loop · ⏱ ~4 min

🎬 Scene — A flowchart loops: request → tool_use? → run tool → append result → request again → end_turn.

🧠 Concept — Tool use is a multi-turn loop driven by stop_reason. Claude asks for a tool; you run it and feed back a tool_result; repeat until end_turn. (Deeper: tool-use.)

🖼 On screen

messages = [{"role": "user", "content": "What's the weather in Berlin?"}]

while True:
    resp = client.messages.create(
        model="claude-sonnet-4-6", max_tokens=1024,
        tools=[weather_tool_schema], messages=messages,
    )
    messages.append({"role": "assistant", "content": resp.content})

    if resp.stop_reason != "tool_use":
        break

    tool_results = []
    for block in resp.content:
        if block.type == "tool_use":
            output = run_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
                "is_error": False,
            })
    messages.append({"role": "user", "content": tool_results})

⚠️ Gotchatool_result goes in a user turn, and its tool_use_id must match the tool_use.id exactly.

Checkpoint — Which role carries tool_result blocks back to Claude? (user.)

🎞 Frame 12 · Writing the tool schema · ⏱ ~3 min

🎬 Scene — A JSON schema is edited; the description field is highlighted as the load-bearing part.

🧠 Concept — A tool is a name, a description, and a JSON input_schema. The description is the most important field — Claude decides whether to call based on it.

🖼 On screen

{
  "name": "get_weather",
  "description": "Get the current weather in a specific location. Use when the user asks about temperature, precipitation, or conditions. Do NOT use for forecasts beyond today.",
  "input_schema": {
    "type": "object",
    "properties": {
      "location": {"type": "string", "description": "City and country, e.g. 'Berlin, Germany'"},
      "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
    },
    "required": ["location"]
  }
}

Checkpoint — Why include when NOT to use a tool in its description? (Prevents over-eager calls; sharpens Claude's routing.)

🎞 Frame 13 · tool_choice and parallel tools · ⏱ ~3 min

🎬 Scene — A single turn returns three tool_use blocks at once; they run side by side.

🧠 Concepttool_choice controls whether/which tool runs. And Claude can emit multiple tool_use blocks in one turn — run them in parallel for a big latency win.

🖼 On screen

tool_choice:
  {"type": "auto"}                  → Claude decides (default)
  {"type": "any"}                   → must call SOME tool, no plain text
  {"type": "tool", "name": "X"}     → force tool X
  {"type": "none"}                  → disable tools this turn

Parallel: return ALL tool_results for a turn in ONE user message.
Requires tools to be independent (no result depends on another).

Checkpoint — Which tool_choice would you set to guarantee Claude calls a specific extraction tool? ({"type": "tool", "name": ...}.)

🎞 Frame 14 · Structured output via a forced tool · ⏱ ~4 min

🎬 Scene — Free-form prose collapses into a clean JSON object that exactly matches a schema.

🧠 Concept — The most reliable way to get JSON is to force a tool whose input_schema is your schema. Claude must emit a tool_use block matching it; you read .input as a dict. (Deeper: structured-output.)

🖼 On screen

extract_tool = {
    "name": "extract_invoice",
    "description": "Extract structured fields from an invoice.",
    "input_schema": {
        "type": "object",
        "properties": {
            "vendor": {"type": "string"},
            "total": {"type": "number"},
            "currency": {"type": "string", "enum": ["USD", "EUR", "GBP"]},
        },
        "required": ["vendor", "total", "currency"],
    },
}

resp = client.messages.create(
    model="claude-sonnet-4-6", max_tokens=1024,
    tools=[extract_tool],
    tool_choice={"type": "tool", "name": "extract_invoice"},
    messages=[{"role": "user", "content": invoice_text}],
)
data = resp.content[0].input   # already a dict matching the schema

🔗 The cheaper fallbacks — prefilling the assistant turn with {, or prompt-only "return JSON" — are less reliable. See structured-output for the ranking.

Checkpoint — Why is a forced tool more reliable than asking "return JSON" in prose? (The schema constrains generation; Claude must produce a conforming tool_use.)


Module 4 — Caching, thinking, and multimodal

🎞 Frame 15 · Prompt caching · ⏱ ~4 min

🎬 Scene — The same 12k-token reference doc is sent twice; the second call's input cost drops to a tenth.

🧠 Concept — Mark a stable prefix as cacheable with cache_control. Later calls sharing that prefix read it from cache at ~10% of input cost and lower latency. (Deeper: prompt-caching.)

🖼 On screen

system=[
    {"type": "text", "text": "You are an expert tax preparer."},
    {
        "type": "text",
        "text": LARGE_TAX_CODE,           # the cacheable prefix
        "cache_control": {"type": "ephemeral"},
    },
]
# Check the hit in the response:
# usage.cache_read_input_tokens > 0  → cache HIT
# sustained cache_creation_input_tokens → you're churning the cache

⚠️ Gotcha — Default cache TTL is 5 minutes and resets on each hit. Any change to the cached prefix — even one character, or adding a tool — invalidates it. Keep volatile data (timestamps, user IDs) after the breakpoint. You may set up to 4 breakpoints.

Checkpoint — After roughly how many reuses does caching pay for its write premium? (~2 reads — write costs ~125%, reads ~10%.)

🎞 Frame 16 · Extended thinking · ⏱ ~3 min

🎬 Scene — A hard reasoning problem; Claude works through a visible "Reasoning" block before the answer.

🧠 Concept — Extended thinking gives Claude a token budget to reason before answering — better on math, multi-step logic, and planning. Enable it and stream thinking blocks into a collapsed UI section. (Deeper: extended-thinking.)

🖼 On screen

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=4096,
    thinking={"type": "enabled", "budget_tokens": 2000},
    messages=[{"role": "user", "content": "Prove there are infinitely many primes."}],
)
# Response content holds `thinking` block(s) THEN the `text` answer.

⚠️ Gotcha — When you continue a conversation that used thinking, preserve the thinking blocks (including signature) in the history, or the next turn errors.

Checkpoint — Where do thinking blocks appear relative to the answer in content? (Before the text block.)

🎞 Frame 17 · Vision and PDFs · ⏱ ~3 min

🎬 Scene — A chart image and a 30-page PDF are dropped into a user turn; Claude answers grounded in them.

🧠 Concept — User turns can carry image and document blocks. Claude reads charts, screenshots, and PDFs directly — great for grounded extraction. (Deeper: vision.)

🖼 On screen

messages=[{
    "role": "user",
    "content": [
        {"type": "image", "source": {
            "type": "base64", "media_type": "image/png", "data": b64_png}},
        {"type": "document", "source": {
            "type": "base64", "media_type": "application/pdf", "data": b64_pdf}},
        {"type": "text", "text": "What are the three key figures, with page numbers?"},
    ],
}]

🔗 Combine with caching: cache a large PDF once, then ask many questions cheaply.

Checkpoint — Which turn role may contain image/document blocks? (Only user.)


Module 5 — Production: batch, errors, cost & latency

🎞 Frame 18 · The Batch API · ⏱ ~3 min

🎬 Scene — 10,000 requests submitted as one job; results land within the hour at half price.

🧠 Concept — The Message Batches API runs thousands of messages.create calls as one async job at 50% off input+output. Use it for offline volume; never for interactive UIs. (Deeper: batch-api.)

🖼 On screen

batch = client.messages.batches.create(
    requests=[
        {"custom_id": "row-001",
         "params": {"model": "claude-haiku-4-5", "max_tokens": 512,
                    "messages": [{"role": "user", "content": "..."}]}},
        # up to 10,000 entries, up to 256MB
    ]
)
# Poll batches.retrieve(batch.id) until processing_status == "ended",
# then read results_url. Map results back via custom_id.

Checkpoint — Batch vs sync vs streaming — match each to: nightly volume / interactive UI / first-token UX. (Batch / sync / streaming.)

🎞 Frame 19 · Errors, retries, and rate limits · ⏱ ~4 min

🎬 Scene — A 429 and a 529 hit the client; an exponential-backoff retry quietly recovers.

🧠 Concept — Production code must distinguish retryable from fatal errors and back off with jitter. The SDK retries some automatically; you own the policy for the rest.

🖼 On screen

import anthropic, time, random

def with_retries(call, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            return call()
        except (anthropic.RateLimitError,            # 429
                anthropic.APIStatusError) as e:      # 529 overloaded, 5xx
            if attempt == max_attempts - 1:
                raise
            sleep = (2 ** attempt) + random.random()
            time.sleep(sleep)                        # exponential backoff + jitter
        except anthropic.BadRequestError:            # 400 — your bug; do NOT retry
            raise

⚠️ Gotcha400 BadRequestError (bad schema, role order, oversized request) is not retryable — fix the request. Retrying it just burns quota.

Checkpoint — Which of 429, 400, 529 should you retry? (429 and 529; never 400.)

🎞 Frame 20 · Cost & latency engineering · ⏱ ~3 min

🎬 Scene — A dashboard of input tokens, output tokens, cache hits, and p95 latency.

🧠 Concept — Cost is mostly input tokens × model price; latency is mostly output tokens + tool time. You have direct levers on both.

🖼 On screen

Cut cost:        cache stable prefixes · drop to Haiku · trim history · Batch offline work
Cut latency:     stream · shorter max_tokens · parallel tools · smaller model
Watch in usage:  cache_read vs cache_creation · input vs output ratio
Mnemonic (CALM): Cache · Align prefixes · Limit history · Monitor tokens

Checkpoint — Name one lever for cost and one for latency that the same change gives you. (Dropping to a smaller model lowers both.)

🎞 Frame 21 · Production patterns · ⏱ ~3 min

🎬 Scene — A reference architecture: app owns state, pins the model, validates output, logs usage, degrades gracefully.

🧠 Concept — Wrap the API behind a service layer you control. The patterns that separate a demo from production are boring on purpose.

🖼 On screen

✓ Pin model IDs; roll forward deliberately.
✓ Own conversation state; bound/compact history.
✓ Validate every structured output; retry with the validator error.
✓ Cap tool-loop iterations (e.g. 20) and surface the cap.
✓ Cache stable prefixes; keep volatile data after the breakpoint.
✓ Retry 429/529 with backoff; never retry 400.
✓ Log usage (tokens, cache hits, stop_reason) per request.
✓ Have a fallback when the model is overloaded.

Checkpoint — Why cap tool-loop iterations? (To stop infinite loops when Claude keeps re-calling a tool; surface the cap as an error.)


🛠 Project

Complete p05-first-api-call — Your First API Call: send a Messages request, parse content, stop_reason, and usage, then add a multi-turn loop that resends history. Then level up through the project chain:

  • p06-tool-use-weather-agent — implement the full tool-use loop with a real get_weather tool, tool_choice, and parallel calls.
  • p07-structured-extraction — force a tool to extract structured JSON from messy text, with validation and a retry-on-invalid loop.
  • p08-prompt-caching-and-rag — cache a large reference document, answer many questions cheaply, and confirm cache hits in usage.

🧪 Self-check quiz

  1. Which three fields are required on every Messages request?
  2. The API is stateless. What does that force your application to do?
  3. What does stop_reason: "tool_use" require you to do next?
  4. In which turn role do tool_result blocks live, and what must their tool_use_id match?
  5. What tool_choice forces a specific tool (e.g. for structured extraction)?
  6. Roughly what fraction of normal input cost is a prompt-cache read? And how many reuses to break even?
  7. Where do thinking blocks appear in content, and what must you preserve to continue the conversation?
  8. Which content blocks can appear only in user turns?
  9. Batch / sync / streaming — match to nightly volume / interactive UI / first-token UX.
  10. Which HTTP errors are retryable, and which is your bug to fix?
<details><summary>Answers</summary>
  1. model, max_tokens, messages. 2. Own and resend the full conversation history each turn. 3. Run the tool, append a tool_result in a user turn, call the API again. 4. The user turn; tool_use_id must match the tool_use.id. 5. {"type": "tool", "name": "..."}. 6. ~10%; breaks even after ~2 reuses (write ~125%). 7. Before the text answer; preserve the thinking blocks including their signature. 8. image and document. 9. Batch / sync / streaming. 10. 429 and 529 (with backoff); 400 is your bug — fix, don't retry.
</details>

🎓 Certificate criteria

You've "passed" Building with the Claude API when you can:

  • Send a Messages request and parse content, stop_reason, and usage.
  • Drive a multi-turn conversation yourself, knowing the API is stateless.
  • Implement a tool-use loop with parallel tools and correct tool_result wiring.
  • Force reliable JSON with a forced tool and validate it.
  • Demonstrate a prompt-cache hit in usage and explain the break-even.
  • Complete p05-first-api-call through p08-prompt-caching-and-rag and journal one cost or latency win you measured.

Tick this course off in progress and record the date you earned Anthropic's official certificate.

🔗 Sources & deeper notes