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+ andpip 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.createrequest and parse every field of the response, includingstop_reasonandusage. - 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, andtool_resultblocks. - 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
| Field | Purpose |
|---|---|
model | Pinned model ID. Always pin — never float to "latest". |
max_tokens | Hard cap on completion length. Required. |
system | System prompt: string or array of content blocks. Persona + rules. |
messages | Alternating user/assistant turns. First must be user. |
tools / tool_choice | Tool schemas and how Claude may use them. |
temperature | 0–1. Lower = more deterministic. |
stop_sequences | Strings that end generation early. |
thinking | {type: "enabled", budget_tokens: N} for extended thinking. |
stream | true for SSE streaming. |
⚠️ Gotcha — max_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
contentzooms in from a plain string into a list of typed blocks.
🧠 Concept — content 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_reasonandusageglow.
🧠 Concept — stop_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.
🧠 Concept — messages must alternate user → assistant → user… 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
| Tier | Use it for | Trade-off |
|---|---|---|
| Opus | Hardest reasoning, agents, deep analysis | Smartest, slowest, priciest |
| Sonnet | Most production work — the default | Balanced |
| Haiku | High-volume classification, extraction, low latency | Fastest, 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)
⚠️ Gotcha — stop_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})
⚠️ Gotcha — tool_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
descriptionfield 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_useblocks at once; they run side by side.
🧠 Concept — tool_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
429and a529hit 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
⚠️ Gotcha — 400 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_weathertool,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
- Which three fields are required on every Messages request?
- The API is stateless. What does that force your application to do?
- What does
stop_reason: "tool_use"require you to do next? - In which turn role do
tool_resultblocks live, and what must theirtool_use_idmatch? - What
tool_choiceforces a specific tool (e.g. for structured extraction)? - Roughly what fraction of normal input cost is a prompt-cache read? And how many reuses to break even?
- Where do
thinkingblocks appear incontent, and what must you preserve to continue the conversation? - Which content blocks can appear only in user turns?
- Batch / sync / streaming — match to nightly volume / interactive UI / first-token UX.
- Which HTTP errors are retryable, and which is your bug to fix?
model,max_tokens,messages. 2. Own and resend the full conversation history each turn. 3. Run the tool, append atool_resultin a user turn, call the API again. 4. Theuserturn;tool_use_idmust match thetool_use.id. 5.{"type": "tool", "name": "..."}. 6. ~10%; breaks even after ~2 reuses (write ~125%). 7. Before thetextanswer; preserve thethinkingblocks including theirsignature. 8.imageanddocument. 9. Batch / sync / streaming. 10.429and529(with backoff);400is your bug — fix, don't retry.
🎓 Certificate criteria
You've "passed" Building with the Claude API when you can:
- Send a Messages request and parse
content,stop_reason, andusage. - Drive a multi-turn conversation yourself, knowing the API is stateless.
- Implement a tool-use loop with parallel tools and correct
tool_resultwiring. - Force reliable JSON with a forced tool and validate it.
- Demonstrate a prompt-cache hit in
usageand 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
- Official course: https://anthropic.skilljar.com/building-with-the-claude-api
- Vault notes: messages-api, system-prompts, tool-use, streaming, structured-output, prompt-caching, extended-thinking, vision, batch-api, multi-turn-conversations
- Next courses: claude-code-101 · introduction-to-mcp · Cert prep: prompt-evaluations