AI EngineeringZero to ProductionHome·About·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

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.

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

For anything a human waits on, stream. converse_stream yields events as the model generates. You accumulate contentBlockDelta events for the text.

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

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.

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