Bedrock & Claude
Bedrock is a second front door to the same Claude models you already know. This chapter is the core inference loop: Converse, streaming, usage, and the raw invoke_model escape hatch.
- AWS credentials (
aws configure) + Bedrock model access enabled in your region +pip install boto3 - AWS credentials (
aws configure) +pip install boto3
Learning objectives
- Call Claude on Bedrock with the Converse API — the modern, model-agnostic way.
- Read the response and usage (token counts) for cost tracking.
- Stream tokens as they generate for responsive UIs.
- Map every Anthropic-SDK concept (system, messages, max_tokens) onto Bedrock.
code/aws2-bedrock-basics/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.Converse: one API for every model essential
Here's the trap this API exists to save you from. You wire your app to Claude on Bedrock, ship it, and it works beautifully. Six months later a cheaper or smarter model lands — maybe a new Claude, maybe a different provider entirely — and your product owner asks to A/B test it. In the old world that meant a rewrite: every provider on Bedrock had its own request body, its own field names, its own way of nesting the reply. Swapping models meant re-plumbing your code. Converse was built precisely so that switch becomes a one-line change instead of a project.
Think of Bedrock as a hotel with many rooms, each a different foundation model, and Converse as a universal keycard. Before Converse, every door took a different physical key — you had to carry a keyring and know which key opened which room. Converse gives you one card that opens them all: the same converse() call, the same messages/system/inferenceConfig shape, whether the room holds Claude, an Amazon model, or a third party. The only thing you change to walk into a different room is the modelId string.
Now the precise definition. Converse is a single, model-agnostic inference API on the bedrock-runtime client that normalizes requests and responses across every provider Bedrock hosts. It standardizes three things you'd otherwise have to look up per model: how you send the system prompt, how you structure messages, and how you set inference parameters like max tokens and temperature. Under the hood Bedrock translates that uniform shape into whatever the underlying model actually expects, then translates the reply back into a uniform response. You get to think in one vocabulary.
The one shape you must internalize — and the mistake nearly everyone makes first — is that message content is a list of typed blocks, never a bare string. You write "content": [{"text": "..."}], not "content": "...". That feels like needless ceremony for a plain-text question, but it's the design decision that pays off later: because content is already a list of blocks, adding an image block or a tool-result block in the same message is just appending another entry — no new API, no different call. The list-of-blocks shape is what lets one API stretch from "ask a question" all the way to multimodal, tool-using agents. If your first Converse call throws a validation error, this is almost always why: you passed a string where Bedrock wanted a list of blocks.
Bedrock's Converse API is the recommended entry point. It gives one consistent request shape across all providers, so switching from Claude to another model is a one-line change. The messages format will feel familiar from the Anthropic SDK.
system/messages/inferenceConfig and Bedrock adapts it to whatever model the modelId names — which is why switching providers is a one-line change, not a rewrite.
Read left to right: your one call on the left flows through the Bedrock runtime in the middle, which fans out to whichever model you named on the right.
- The blue box on the left is the single uniform request you write —
system, amessageslist of{text}blocks, andinferenceConfig. You author this exact shape no matter which model you target. - The white box in the middle is the
bedrock-runtimeclient. The blue arrows show it translating your uniform request into each provider's native format and translating the reply back out — that adaptation is the work Converse does for you. - The green, amber, and purple boxes are the different models it can reach (Claude, an Amazon model, a third party). The payoff is the bottom bar: to point at a different one you change only the
modelIdstring — the call itself never changes.
In short: write the request once in Converse's vocabulary and the modelId is the only knob you turn to swap models.
converse.pyimport boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
resp = brt.converse(
modelId=MODEL,
system=[{"text": "You are a careful SRE assistant. Be concise."}],
messages=[{"role": "user", "content": [{"text": "In one sentence, what is Amazon Bedrock?"}]}],
inferenceConfig={"maxTokens": 200, "temperature": 0.2},
)
print(resp["output"]["message"]["content"][0]["text"])
u = resp["usage"]
print("tokens in/out:", u["inputTokens"], u["outputTokens"])
Amazon Bedrock is a fully managed AWS service that provides API access to foundation models — including Anthropic's Claude — for building generative-AI apps.
tokens in/out: 28 41
This is the whole point of the chapter in one screen: call Claude — the same model family you know from the Anthropic SDK — but through Amazon Bedrock instead. Bedrock is AWS's managed door to many foundation models; here you send it a question and print Claude's answer plus how many tokens it used.
import boto3pulls in AWS's official Python library. Everything you do with AWS from Python goes through boto3.boto3.client("bedrock-runtime", region_name="us-east-1")creates the Bedrock runtime client — the object that actually sends inference requests.bedrock-runtimeis the 'run a model' service (a separatebedrockservice handles setup/admin).region_namepicks which AWS data-center region answers you, because model access is enabled per region.MODEL = "anthropic.claude-3-5-sonnet-..."is Bedrock's model ID — the exact Claude version to call. On Bedrock you name models by these IDs rather than short names.brt.converse(...)is the Converse API — the modern, model-agnostic call.system=[{"text": ...}]sets the standing instruction (persona/rules).messages=[{"role": "user", "content": [{"text": ...}]}]is the actual question. Notecontentis a list of blocks, each a{"text": ...}dict — that list shape is what later lets you add images or tool calls the same way.inferenceConfig={"maxTokens": 200, "temperature": 0.2}are the knobs:maxTokenscaps how long the reply can be;temperaturenear 0 makes answers more focused and repeatable (higher = more creative/random).resp["output"]["message"]["content"][0]["text"]reaches into the nested response to pull out the first text block — Claude's actual sentence.resp["usage"]holdsinputTokens/outputTokens, which you print for cost tracking (you pay per token).
What the output means: The first printed line is Claude's one-sentence answer. The second line, tokens in/out: 28 41, means the request cost 28 input tokens and the reply used 41 output tokens — multiply those by the model's per-token price to get the dollar cost.
Try this: Change the user question, or bump temperature to 0.9 and run twice — at high temperature you'll notice the wording changes between runs; at 0.2 it stays steady. (Running for real needs AWS credentials and Bedrock model access — see the box at the top.)
system, messages, maxTokens — identical concepts. The content is a list of blocks ({"text": ...}), which is what lets Bedrock handle images and tool calls with the same shape.Streaming essential
You've felt this failure as a user. You type a question into a chatbot, hit enter, and then… nothing. A spinner. Three seconds, five, eight — long enough that you start wondering whether it crashed. Then, all at once, a wall of text appears. The model wasn't slow to think; it finished the first words almost immediately. It was slow to show, because your code waited for the entire completion before rendering a single character. Streaming fixes exactly this: it lets the words appear as they're generated, so the wait feels like a tenth of what it is.
The analogy is a phone call versus a letter. A non-streamed call (converse) is a letter: the other side writes the whole thing, seals it, and you read it only when it arrives complete. A streamed call (converse_stream) is a live phone conversation: you hear each word as it's spoken. The total talking time is identical — the model generates tokens at the same rate either way — but on the phone you're engaged from the first syllable instead of staring at a sealed envelope. That difference in perceived latency is why every serious chat UI streams.
Mechanically, here's what changes. converse_stream makes the same request as converse, but instead of returning one finished reply it returns a stream of events you iterate over as they arrive. Those events describe the life of the response: the message starting, a content block opening, small deltas of text arriving one after another, the block closing, the message ending, and finally a metadata event carrying token usage. The word delta is the key one — it means "a little more" — and the incremental text you actually want to print lives specifically inside the contentBlockDelta events, under delta.text.
The common mistake is to treat every event as if it carries text and blow up on the ones that don't (the start, stop, and metadata events have no delta). The correct pattern is to filter for the event kind you care about — keep only contentBlockDelta, read its text chunk, and ignore the rest of the lifecycle. And because streaming hands you the pieces rather than a finished object, if you also need the full text or the token usage at the end, it's on you to accumulate the deltas into a string and read the final metadata event yourself. Nothing is harder here than a plain converse call — you're just consuming the answer as a sequence instead of a single blob.
For anything a human waits on, stream. converse_stream yields events as the model generates. You accumulate contentBlockDelta events for the text.
contentBlockDelta events' delta.text, which you filter for and accumulate — the start, stop, and metadata events are skipped.
Follow the dashed time arrow left to right — that's the order events come out of converse_stream as the model generates.
- The white boxes (
messageStart,contentBlockStop,messageStop) are lifecycle markers with no text — they announce the response beginning and ending. - The green Delta boxes in the middle are the
contentBlockDeltaevents; each carries one small chunk underdelta.text. The blue arrows show those chunks being pulled down and joined into the growing reply. - The amber box on the right is the final
metadataevent holding tokenusage— read it at the end if you need the counts. The payoff is the blue bar: keep only the green events and stitch their text into the live answer.
In short: a stream is mostly lifecycle noise — filter for contentBlockDelta, accumulate delta.text, and grab usage from the closing metadata event.
stream.pyimport boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
stream = brt.converse_stream(
modelId=MODEL,
messages=[{"role": "user", "content": [{"text": "Count to five slowly."}]}],
inferenceConfig={"maxTokens": 100},
)
for event in stream["stream"]:
if "contentBlockDelta" in event:
print(event["contentBlockDelta"]["delta"]["text"], end="", flush=True)
print()
Same Converse call, but instead of waiting for the whole answer, this streams it — you get the text piece by piece as Claude generates it. That's what makes a chat UI feel alive, showing words as they arrive instead of a long pause then a wall of text.
- The client, model ID, and
messagesare set up exactly like Lab W2.1 — nothing new about how you talk to Bedrock. brt.converse_stream(...)is the streaming twin ofconverse. Instead of one finished reply, it hands back a stream of events that arrive over time.for event in stream["stream"]:loops over those events as they come in. Each event is a small dict describing something that just happened (text arrived, message started, message ended, etc.).if "contentBlockDelta" in event:keeps only the events that carry a new chunk of text. A delta means 'a little more of the answer'.event["contentBlockDelta"]["delta"]["text"]is that chunk.print(..., end="", flush=True)prints each chunk with no line break (end="") and forces it to the screen immediately (flush=True) so the text appears live. The final bareprint()just adds a newline at the end.
What the output means: On screen you'd see the answer type itself out — "1... 2... 3..." appearing progressively — rather than all at once. The end result is the same text as a non-streamed call; only the delivery is incremental.
Try this: Add a counter that increments inside the if and print it at the end to see how many text deltas the reply arrived in — a short answer might be a handful, a long one dozens.
InvokeModel: the raw escape hatch intermediate
Sooner or later the tidy abstraction won't have the knob you need. You're reading Claude's documentation, you find a parameter that does exactly what your feature requires — and it's nowhere in the Converse request shape. Or you inherit a codebase written before Converse existed and every model call is in some older, chattier form you don't recognize. This is the moment you drop one level down, from the polished universal keycard to the master key: invoke_model.
The analogy: Converse is an automatic car — it shifts gears for you and you rarely think about it. invoke_model is manual transmission. You now control the clutch, which means you can do things the automatic won't let you, but you also have to operate everything yourself and it's easier to stall. Concretely, with invoke_model you hand Bedrock the provider's native request body verbatim — you build the exact JSON that model expects, serialize it yourself, send it, then read and parse the raw response yourself. Converse's conveniences — uniform field names, automatic JSON encoding, automatic decoding — all become your job.
Here's the precise mental model. invoke_model is the lower-level, provider-specific call: there is no shared shape, so what you send depends entirely on which model you're invoking. For Claude, that body is the Anthropic Messages format, exactly as you'd send it to the Anthropic API directly — which is a genuine payoff, because everything you already know about calling Claude natively transfers here unchanged. Note the details that shift from Converse: the field names are snake_case (max_tokens, not maxTokens), and there is one non-negotiable field that trips up everyone porting from the SDK — anthropic_version, set to "bedrock-2023-05-31". Omit it and the call fails; it's Bedrock's way of pinning which Claude request schema you're speaking.
The mistake to avoid is reaching for invoke_model by default because it feels closer to the metal. It isn't a better API — it's a more manual one, and every call site you write in native JSON is a call site you'll have to rewrite by hand the day you switch models. The rule of thumb: prefer Converse for all new code, and drop to invoke_model only for the two specific reasons above — a provider-specific field Converse doesn't surface, or legacy code you must match. It's an escape hatch, not a front door.
Before Converse existed, you called invoke_model with each provider's native JSON body. You will still see it in older code and when you need a Claude-specific field Converse does not surface. For Claude the body is the Anthropic Messages format verbatim.
invoke_model (amber) hands you the native body — you supply anthropic_version, json.dumps it, and json.loads the reply yourself. Prefer the left; drop right only when you must.
Compare the two columns side by side — the same request handled two ways, from top (what you send) to bottom (how you read the reply).
- The green column on the left is
converse: you pass a uniform dict and everything below it — field naming, JSON encoding, and decoding the response — is done for you inside Bedrock. - The amber column on the right is
invoke_model: each row is a job that becomes yours. You must includeanthropic_version, build the native snake_case body,json.dumpsit before sending, andjson.loadsthe raw reply after. - The aha is that the two columns produce the same answer — the only difference is how much plumbing you hand-write. More manual rows on the right means more code to rewrite the day you switch models.
In short: Converse absorbs the encoding/decoding work; invoke_model gives you the native body and the manual steps — an escape hatch, not a default.
invoke_model.pyimport boto3, json
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
body = {
"anthropic_version": "bedrock-2023-05-31", # required for Claude on Bedrock
"max_tokens": 200,
"messages": [{"role": "user", "content": "Say hello."}],
}
resp = brt.invoke_model(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
body=json.dumps(body),
)
payload = json.loads(resp["body"].read())
print(payload["content"][0]["text"])
This is the older, lower-level way to call a model on Bedrock: invoke_model. Instead of the tidy Converse shape, you hand Bedrock the provider's native JSON body yourself. For Claude that body is the raw Anthropic Messages format. You'll meet this in legacy code and when you need a Claude-specific field Converse doesn't expose.
import boto3, json— you now also needjsonbecause you build the request body as a dict and turn it into a JSON string by hand.- The
bodydict is Claude's native format."anthropic_version": "bedrock-2023-05-31"is a required tag telling Bedrock which Claude request schema you're using.max_tokensandmessageslook like the Anthropic SDK — note the snake_case names here vs. Converse'smaxTokens. brt.invoke_model(modelId=..., body=json.dumps(body))sends it.json.dumps(body)converts your Python dict into the JSON text string the API expects.payload = json.loads(resp["body"].read())reverses that on the way back: the response body is a raw stream, so you.read()the bytes andjson.loadsparses them into a Python dict.payload["content"][0]["text"]pulls the answer out of that dict — the same list-of-blocks shape Claude always returns.
What the output means: Prints Claude's reply to "Say hello." The result is identical to what Converse would give; the difference is purely that you assembled and parsed the JSON yourself.
Try this: Compare this block to Lab W2.1 side by side. Everything Converse did automatically — naming fields, encoding JSON, decoding the response — you did manually here. That's exactly why the tip below says prefer Converse for new code and keep invoke_model for special cases.
converse/converse_stream for new code. Reach for invoke_model only for provider-specific parameters or to match legacy code.Exercise W2.1 — Port your Anthropic call
Context: The Converse API is deliberately close to, but not identical to, the direct Anthropic SDK shape. Porting a call you already know is the fastest way to internalize the differences — list-of-blocks content, separate system, and usage on the response.
Your task: Take a Claude call you wrote against the Anthropic SDK and rewrite it with converse, confirm the output and token counts match your expectation, then add streaming so the answer prints as it generates.
Requirements:
- Reproduce an existing Anthropic-SDK call using
bedrock-runtimeconverse - Confirm the reply text is equivalent and read token counts from
resp["usage"] - Convert the call to
converse_streamand printcontentBlockDeltatext as it arrives - Note the shape differences you hit: content is a list of blocks and
systemis its own top-level field
💡 Hint: Map the SDK's plain-string message content onto Converse's [{"text": ...}] blocks first — that one transform fixes most porting errors.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The Converse API is Bedrock's unified, provider-agnostic way to chat with any model, and its one quirk trips up every newcomer: content is always a list of blocks, never a bare string. Getting the first call right anchors everything that follows.
Your task: Call converse on bedrock-runtime with a single user message and print the reply text.
Requirements:
- Use a
bedrock-runtimeclient with a region - Pass a
messageslist where content is[{"text": ...}], not a raw string - Set an
inferenceConfig(e.g.maxTokens,temperature) - Read the answer from
output.message.content[0].text
💡 Hint: Both the request content and the response content are lists of typed blocks — the text you want is inside the first block, not at the top level.
Show solution
Converse content is always a list of blocks; the answer is at output.message.content[0].text.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
resp = brt.converse(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role": "user", "content": [{"text": "Define latency in one line."}]}],
inferenceConfig={"maxTokens": 128, "temperature": 0.2},
)
print(resp["output"]["message"]["content"][0]["text"])
Context: A system prompt is how you set the model's role and constraints, and usage on every response is your running meter for cost. Wiring both in early means every later call is already observable.
Your task: Add a system block to steer the model's behaviour and print the input and output token counts from the response.
Requirements:
- Pass
systemas a list of{"text": ...}blocks, mirroring the message-content shape - The system instruction visibly shapes the reply (e.g. 'answer in exactly one sentence')
- Read
inputTokensandoutputTokensfromresp["usage"] - Understand that
usageis what you log for per-call cost tracking
💡 Hint: system is a sibling of messages at the top level of the call — and it takes the same list-of-blocks shape as message content.
Show solution
system is a list of {"text": ...} blocks; resp["usage"] carries token counts for cost tracking.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
resp = brt.converse(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
system=[{"text": "You answer in exactly one sentence."}],
messages=[{"role": "user", "content": [{"text": "What is a VPC?"}]}],
inferenceConfig={"maxTokens": 128},
)
u = resp["usage"]
print(u["inputTokens"], u["outputTokens"])
Context: For a chat UI, waiting for the full completion before showing anything feels broken. ConverseStream emits incremental events so text appears as it's generated — but you have to pick the right event type out of the stream.
Your task: Use converse_stream and print text as it arrives, filtering for contentBlockDelta events.
Requirements:
- Call
converse_streaminstead ofconverse - Iterate the
streamgenerator of event dicts - Select only
contentBlockDeltaevents and readdelta.text - Print incrementally (no newline per delta) so the output reads as one flowing response
💡 Hint: The stream carries several event kinds (start/delta/stop/metadata); the running text lives specifically under a delta event's delta.text.
Show solution
The stream yields event dicts; the incremental text is under contentBlockDelta.delta.text.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
resp = brt.converse_stream(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role":"user","content":[{"text":"Count to five."}]}],
)
for event in resp["stream"]:
if "contentBlockDelta" in event:
print(event["contentBlockDelta"]["delta"]["text"], end="")
Context: Converse normalizes every provider, but sometimes you need a provider-specific field it doesn't expose. InvokeModel is the escape hatch: you hand Bedrock the model's native request body verbatim — and for Claude that body has one non-negotiable field.
Your task: Call the same model through invoke_model using the native Claude request body, including the required anthropic_version, and parse the JSON response.
Requirements:
- Build the native (snake_case) Claude body with
max_tokensandmessages - Include
anthropic_versionset to"bedrock-2023-05-31"— the call fails without it - Serialize the body to a JSON string for the
bodyparameter - Read and
json.loadsthe streamingbodyof the response, then pull text fromcontent[0].text
💡 Hint: Unlike Converse's tidy dict, invoke_model gives you a raw byte stream back — you must .read() and decode the JSON yourself.
Show solution
invoke_model takes a JSON string body in the provider-native (snake_case) shape; anthropic_version is required.
import boto3, json
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
body = {
"anthropic_version": "bedrock-2023-05-31", # required
"max_tokens": 128,
"messages": [{"role":"user","content":"Say hi."}],
}
resp = brt.invoke_model(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
body=json.dumps(body),
)
out = json.loads(resp["body"].read())
print(out["content"][0]["text"])
Context: Scattering raw converse calls across a codebase means no single place to add logging, retries, or guardrails later. A thin wrapper collapses each call site to one line and gives you exactly that seam.
Your task: Wrap converse in ask(prompt) that returns the reply text and logs token usage, so every call site is a single call.
Requirements:
asktakes a prompt (and a defaulted model id) and returns just the answer text- Token usage is logged from
resp["usage"]on every call - The message-list construction and
inferenceConfigare hidden inside the wrapper - The wrapper is the natural home for future retries, guardrails, or metrics
💡 Hint: Centralizing the call is the whole point — keep the public surface a plain string in, string out, and let usage logging be a side effect.
Show solution
Centralizing the call gives one place to log usage and later add retries or guardrails.
import boto3, logging
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
log = logging.getLogger("llm")
def ask(prompt, model="anthropic.claude-3-5-sonnet-20241022-v2:0"):
r = brt.converse(
modelId=model,
messages=[{"role":"user","content":[{"text": prompt}]}],
inferenceConfig={"maxTokens": 512},
)
u = r["usage"]
log.info("tokens in=%s out=%s", u["inputTokens"], u["outputTokens"])
return r["output"]["message"]["content"][0]["text"]
# print(ask("Summarize the CAP theorem."))
Context: Converse is stateless: it has no memory of prior turns, so a support bot only sounds coherent because you replay the transcript each call. Owning that history — and bounding it — is what keeps follow-ups contextual without an unbounded cost.
Your task: Maintain the messages list across turns for a support endpoint: append the user turn, call converse, append the assistant turn, and cap history length.
Requirements:
- Each turn appends the user message, calls converse, then appends the returned assistant
messageverbatim - History is capped (e.g. trim to the last N turns) to bound context size and cost
- The assistant turn stored is the full
output.message, so it round-trips as valid input next call - The transcript-management state machine is exercisable offline, independent of the network call
- Understand that Bedrock keeps no session — the client owns the entire conversation
💡 Hint: Store the model's own output.message object back into the list unchanged; slicing to the last 2 * max_turns entries is enough to bound it.
Show solution
Converse is stateless: you own the transcript. Append both roles each turn and trim to bound cost.
import boto3
brt = boto3.client("bedrock-runtime", region_name="us-east-1")
class Chat:
def __init__(self, max_turns=10):
self.messages, self.max = [], max_turns * 2
def send(self, text):
self.messages.append({"role":"user","content":[{"text": text}]})
r = brt.converse(
modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=self.messages,
inferenceConfig={"maxTokens": 512},
)
msg = r["output"]["message"]
self.messages.append(msg) # keep assistant turn
self.messages = self.messages[-self.max:] # bound history
return msg["content"][0]["text"]
# offline: history bookkeeping without a network call
c = Chat(max_turns=1)
c.messages = [{"role":"user","content":[{"text":"a"}]}]*5
c.messages = c.messages[-c.max:]
print(len(c.messages)) # 2
✓ Checkpoint — you can move on when you can…
- Call Claude on Bedrock with
converseand read the text + usage. - Stream a response and explain what a
contentBlockDeltais. - Explain when you would drop to
invoke_modelinstead of Converse. - Map
system/messages/maxTokensbetween the Anthropic SDK and Bedrock.
Knowledge check check yourself
Why does the course recommend the Converse API over invoke_model for new code, and when would you still reach for invoke_model?
Show answer
When you stream a Bedrock response with converse_stream, what is a contentBlockDelta and why do you filter for it?