AI EngineeringZero to ProductionHome·About·What’s new·Contact
AWS AI Automation · Chapter W2

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.

⏱️ ~1.5 hours🧪 3 labs🎯 Beginner→Intermediate
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • AWS credentials (aws configure) + Bedrock model access enabled in your region + pip install boto3
  • AWS credentials (aws configure) + pip install boto3
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

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.
▶ Runnable companionEvery code block in this lesson is also a standalone file under 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.

write ONE shape → Bedrock adapts it per model → only the modelId string changes your converse() call system=[…] messages=[{text}] inferenceConfig one uniform request bedrock-runtime translates in & out per provider Claude Amazon model 3rd-party model swap models = change modelId only — same call One request shape, every model. You always write the same 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.
🗺️ How to read this diagram

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, a messages list of {text} blocks, and inferenceConfig. You author this exact shape no matter which model you target.
  • The white box in the middle is the bedrock-runtime client. 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 modelId string — 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab W2.1
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
▶ How this works

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.

  1. import boto3 pulls in AWS's official Python library. Everything you do with AWS from Python goes through boto3.
  2. boto3.client("bedrock-runtime", region_name="us-east-1") creates the Bedrock runtime client — the object that actually sends inference requests. bedrock-runtime is the 'run a model' service (a separate bedrock service handles setup/admin). region_name picks which AWS data-center region answers you, because model access is enabled per region.
  3. 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.
  4. 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. Note content is a list of blocks, each a {"text": ...} dict — that list shape is what later lets you add images or tool calls the same way.
  5. inferenceConfig={"maxTokens": 200, "temperature": 0.2} are the knobs: maxTokens caps how long the reply can be; temperature near 0 makes answers more focused and repeatable (higher = more creative/random).
  6. resp["output"]["message"]["content"][0]["text"] reaches into the nested response to pull out the first text block — Claude's actual sentence. resp["usage"] holds inputTokens/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.)

Same Claude, different doorCompare this to cl2-api: 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.

events arrive over time → keep only the delta events, ignore the lifecycle ones time → message Start Delta delta.text Delta delta.text Delta delta.text contentBlock Stop message Stop metadata usage accumulate → the live reply text Only the green events carry text. A stream is a timeline of lifecycle events; the incremental answer lives in the contentBlockDelta events' delta.text, which you filter for and accumulate — the start, stop, and metadata events are skipped.
🗺️ How to read this diagram

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 contentBlockDelta events; each carries one small chunk under delta.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 metadata event holding token usage — 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.

Lab W2.2
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()
▶ How this works

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.

  1. The client, model ID, and messages are set up exactly like Lab W2.1 — nothing new about how you talk to Bedrock.
  2. brt.converse_stream(...) is the streaming twin of converse. Instead of one finished reply, it hands back a stream of events that arrive over time.
  3. 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.).
  4. 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.
  5. 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 bare print() 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.

same request, two levels — Converse does the plumbing; invoke_model hands it to you converse — automatic uniform dict (list-of-blocks) Bedrock names fields for you JSON encode + decode: hidden read output.message.content invoke_model — manual anthropic_version native snake_case body json.dumps(body) yourself json.loads(resp) yourself Same call, two levels of control. Converse (green) hides field naming and JSON encode/decode; 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.
🗺️ How to read this diagram

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 include anthropic_version, build the native snake_case body, json.dumps it before sending, and json.loads the 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.

Lab W2.3
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"])
▶ How this works

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.

  1. import boto3, json — you now also need json because you build the request body as a dict and turn it into a JSON string by hand.
  2. The body dict 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_tokens and messages look like the Anthropic SDK — note the snake_case names here vs. Converse's maxTokens.
  3. 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.
  4. payload = json.loads(resp["body"].read()) reverses that on the way back: the response body is a raw stream, so you .read() the bytes and json.loads parses them into a Python dict.
  5. 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.

Prefer ConverseUse 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-runtime converse
  • Confirm the reply text is equivalent and read token counts from resp["usage"]
  • Convert the call to converse_stream and print contentBlockDelta text as it arrives
  • Note the shape differences you hit: content is a list of blocks and system is 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.

Exercise 1 · A first Converse callBeginner

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-runtime client with a region
  • Pass a messages list 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"])
Exercise 2 · Add a system prompt and read usageIntermediate

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 system as 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 inputTokens and outputTokens from resp["usage"]
  • Understand that usage is 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"])
Exercise 3 · Stream the responseAdvanced

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_stream instead of converse
  • Iterate the stream generator of event dicts
  • Select only contentBlockDelta events and read delta.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="")
Exercise 4 · The raw invoke_model escape hatchExpert

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_tokens and messages
  • Include anthropic_version set to "bedrock-2023-05-31" — the call fails without it
  • Serialize the body to a JSON string for the body parameter
  • Read and json.loads the streaming body of the response, then pull text from content[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"])
Exercise 5 · A thin Converse wrapper with usage loggingProfessional

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:

  • ask takes 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 inferenceConfig are 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."))
Exercise 6 · Multi-turn chat state for a support botIndustry scenario

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 message verbatim
  • 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 converse and read the text + usage.
  • Stream a response and explain what a contentBlockDelta is.
  • Explain when you would drop to invoke_model instead of Converse.
  • Map system/messages/maxTokens between the Anthropic SDK and Bedrock.

Knowledge check check yourself

✓ Knowledge check

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
Converse gives one consistent, model-agnostic request shape, so switching models is a one-line change and it handles field naming/JSON encoding/decoding for you. Drop to invoke_model only for provider-specific parameters Converse doesn't surface, or to match legacy code.
✓ Knowledge check

When you stream a Bedrock response with converse_stream, what is a contentBlockDelta and why do you filter for it?

Show answer
converse_stream yields a sequence of events over time; a contentBlockDelta is an event carrying the next incremental chunk of the answer's text. You keep only those events and accumulate their delta['text'] to build the reply live, ignoring the other lifecycle events.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in