[!NOTE] This is Video 1 of 2 β the full design walkthrough and real AWS build. Video 2 (hands-on live demo) is coming soon.
Mike opens the assistant that’s answered every payment question so far and asks something new: “What’s my current balance?”
Every pattern before this one β Pattern 01, Pattern 02, Pattern 03, Pattern 04 β solved a real problem in the same assistant, and each fix quietly assumed one thing: that the app already knows, before the model is even called, exactly what data the model will need. That assumption breaks on a question like this one. A balance has to be current, and the app can’t know in advance which of a dozen possible questions Mike is about to ask.
This is the pattern that lets the model ask for its own data, live, instead of reasoning from whatever the app guessed it might need.
The picture above is the whole idea at 30,000 feet: the model doesn’t answer immediately β it tells the app what it needs, the app fetches it, and only then does the model compose the final answer.
[!NOTE] This post is part of a continuing 16-pattern series: a payment-support assistant at a fictional bank, starting simple and accumulating exactly the complexity a real one would, in the order a real one would need it. Read the series overview or clone the GitHub repo to follow along.
Where the first four patterns left off
Same assistant, same story, one new architectural gap closed per pattern.
Pattern 01 β Grounded RAG. Mike asks why his payment is pending. The assistant gives a fluent, completely fabricated answer β it reasoned from training data, not his real transaction. The fix: retrieve the real record first, generate the answer second. Left open: retrieval finding the right document doesn’t guarantee the model draws the right conclusion from it.
Pattern 02 β Prompt and Context Contract. A second team plugs their own data into Pattern 01’s assistant. It crashes with a raw KeyError, two layers below where the actual problem was. The fix: validate the shape of every input before rendering a prompt with it. Left open: a contract only checks shape β not whether a correctly-typed value is actually the right value.
Pattern 03 β Structured Output and Validation. A dispute’s status comes back as "still being looked at" β present, correctly typed, so it passes Pattern 02’s contract clean. But the routing queue only recognizes four exact enum values, and this isn’t one of them β the dispute sits unrouted, no error, no alert. The fix: constrain the model at generation time so this exact failure can’t be produced. Left open: a schema-valid value still isn’t automatically the correct one.
Pattern 04 β Evaluator Optimizer. Mike asks when his refund arrives. The model answers confidently: “by Friday.” Policy says 5β10 business days β Friday is impossible. The fix: a second, independent model call grades the first against the same policy context, and a failing grade triggers a rewrite. Left open β and this is the sentence the rest of this post exists to answer β the judge only ever grades the draft against whatever context the app already fetched. It has no way to know if that pre-fetched data was itself stale by the time the question was actually asked.
Two things worth clearing up before going further
“Doesn’t Pattern 01 already use a real database? What’s actually new here?”
Yes β Pattern 01’s lookup already hits a real datastore in production. Swap the local file for a live DynamoDB call and nothing about Pattern 01’s architecture changes. The datastore itself was never the variable across this series.
What actually changes at this pattern is who decides to go fetch data, and when. In Patterns 01 through 04, that decision is made once, by the developer, before the model ever reads the question. From this pattern on, the model itself makes that decision, live, in the middle of composing its answer.
“So now that the model can call tools, do we still need the contract and schema checks from Patterns 02 and 03?”
More than ever, not less. Handing a model the ability to trigger a real function call is more power than handing it a paragraph of pre-fetched context β and more power calls for more boundary discipline, not less. Every earlier pattern’s job still runs here, just relocated:
- Pattern 02’s contract now enforces the tool’s input, not the prompt’s. The server unconditionally overwrites whatever
account_idthe model supplies with the authenticated session’s real value, on every single tool call β skipping this is a textbook confused-deputy vulnerability. - Pattern 03’s structured output now constrains what the model is allowed to request, via a JSON Schema on every tool definition.
- Pattern 04’s evaluator still applies, unchanged, to the model’s final answer. Fresh, correctly-fetched data doesn’t guarantee the sentence built from it is accurate. Live data fixes staleness. It does not fix reasoning errors.
The whole series so far, on one page
| Pattern | Mike asks… | What broke | One-line fix | Who decides what data to fetch |
|---|---|---|---|---|
| 01 β Grounded RAG | “Why is my payment pending?” | Model made up a fluent, wrong answer | Look up the real record first, generate second | App β hardcoded |
| 02 β Contract | (a second team plugs in their data) | Raw KeyError, two layers deep | Validate the shape before you render | β |
| 03 β Structured output | Dispute status comes back | Correct shape, wrong exact value β silently unrouted | Constrain the model so that value can’t be generated | β |
| 04 β Evaluator | “When will my refund arrive?” | Schema-valid answer, still factually wrong | A second call grades the first against real policy | β |
| 05 β Tool calling | “What’s my balance right now?” | Every fix above still trusts one snapshot, fetched once, before the question was read | Let the model ask for fresh data mid-answer | The model, at runtime |
What tool calling actually looks like
One model call becomes two.
Call 1 β the question, plus a menu of tools the model is allowed to use:
response = client.converse(
modelId="amazon.nova-micro-v1:0",
messages=[{"role": "user", "content": [{"text": "What is my balance?"}]}],
toolConfig={
"tools": [{
"toolSpec": {
"name": "get_balance",
"description": "Retrieve the current account balance for a given account ID.",
"inputSchema": {"json": {
"type": "object",
"properties": {"account_id": {"type": "string"}},
"required": ["account_id"],
}},
}
}],
"toolChoice": {"auto": {}},
},
)
The model doesn’t answer yet. It returns stopReason="tool_use" and names the tool it wants and the arguments it wants to call it with. It has signaled intent β it cannot execute anything itself.
The app executes the tool β and this is where the contract enforcement lives:
tool_input = dict(tool_use["input"])
tool_input["account_id"] = account_id # authenticated session value β never trust the model's own
result = get_balance(tool_input["account_id"])
Call 2 β send the real result back, the model composes the final answer:
response = client.converse(
modelId="amazon.nova-micro-v1:0",
messages=[user_msg, assistant_tool_use_msg, tool_result_msg],
toolConfig=TOOL_CONFIG,
)
# stopReason == "end_turn" β this is the real, live answer
The model can request more than one tool in a single turn β a compound question like “my balance and my recent transactions” returns two tool-use blocks, executed together, answered together. If the model needs a second round after seeing the first result, the loop continues, bounded by a fixed round limit so it can never run forever.
That’s the full loop end to end, including the two guardrails that don’t show up in the two-call summary above: the app never trusts the model’s own account_id, and a bounded round counter stops a chain of tool calls from running forever.
Which variation do you actually need?
Tool calling isn’t one fixed shape β the demo above is the baseline, but real questions call for small, deliberate variations on it.
| Your situation | Reach for | Why not the alternative |
|---|---|---|
| One question, one data source | Single-tool | Parallel tools solve a problem you don’t have yet |
| One question needs two or more lookups at once | Parallel tools | Sequential calls double the round trips for no reason |
| The app already knows which tool is correct | toolChoice forced | Letting the model route when you already know the answer just adds latency |
| A genuinely multi-step investigation (4+ sequential calls) | Raise MAX_TOOL_ROUNDS first | Only escalate further if the loop itself, not just the round count, is the real constraint |
| Autonomous multi-step chaining with managed session memory | Bedrock Agents | A genuinely different, managed architecture β not a bigger version of this one |
Every row except the last is still this same pattern β one parameter or one code path changed, same explicit, auditable two-call loop underneath. The moment a requirement pushes past that last row, the honest answer is “that’s a different architecture,” not “we can bolt it on.”
This is the path this series actually built and demoes on camera β a single Lambda owns the two-call loop end to end, so every tool call, every enforced account_id, and every round of the loop is visible in one place, in your own logs.
Bedrock Agents is the managed alternative β same underlying idea, but the loop itself runs inside the service instead of your own code.
toolConfig vs. Bedrock Agents, the actual dissection: with toolConfig, every call is visible, logged, and auditable in app code β the loop mechanics are the lesson here, and that matters for a compliance-facing payment flow. Bedrock Agents is the right tool when you want autonomous multi-step chaining with session memory and don’t need per-call audit visibility β genuinely valid, just not the fit for this pattern’s problem.
Already on a framework? LangGraph’s ToolNode and Strands Agents both reach the same underlying toolConfig/toolUse mechanics β LangGraph via an AWS-published sample, Strands via its @tool decorator, which generates the same toolSpec this post hand-builds. Neither replaces the explicit, auditable loop this pattern teaches; they wrap it for you instead of hand-writing it.
When this pattern is the right call, and when it isn’t
Reach for tool calling when the shape of the question genuinely can’t be predicted in advance, or the data has to be live at the exact moment the model answers β a balance, a real-time status, anything the app can’t safely pre-fetch without either over-fetching “just in case” or under-fetching and answering from something already stale.
Don’t reach for it when the data barely changes and the question shape is predictable β Pattern 01’s “why is my payment pending” is always the same lookup, and a single model call handles it more simply and more cheaply than two. Tool calling adds a full extra round trip; that cost only earns its keep when the flexibility is actually needed.
Where this goes next
Tool calling is the foundation the rest of the agentic patterns in this series build on: a model that can pause mid-answer, request something it needs, and continue. Pattern 06 (Memory) and Pattern 07 (Workflow Orchestration) both extend this same loop rather than reinventing it.
[!TIP] Star the GitHub repo and follow along with the series.
π― Interview Prep β Pattern 05
Scenario-based questions an interviewer would ask about this pattern. Try answering out loud before expanding each one. Full guide, all patterns: Interview Guide β
Q1 What is the fundamental difference between Pattern 01-04 and Pattern 05?
P01-P04 all share the same model: the app pre-fetches context (transaction data, policy text) and passes it IN to the model. The model reasons from whatever it was given. If the data was stale, incomplete, or wrong β the model reasons from wrong information.
Pattern 05 inverts this: the model calls OUT. The app sends the question and a tool registry. The model decides which tool to call and with what inputs. The app executes the tool and returns real, live data. The model then composes the answer from the actual result β not from pre-fetched context.
The architectural consequence: in P01-P04, the app decides what data to provide. In P05, the model decides what data to request. This shifts retrieval control from the app to the model.
Q2 Walk me through the two-call Converse API cycle for tool calling.
Call 1 β question + toolConfig:
response = client.converse(
modelId="amazon.nova-micro-v1:0",
messages=[{"role": "user", "content": [{"text": question}]}],
toolConfig={"tools": [...], "toolChoice": {"auto": {}}},
)
If the model needs a tool: response["stopReason"] == "tool_use". The content list contains one or more toolUse blocks: {toolUseId, name, input}.
App executes the tool: calls run_tool(name, input), gets real data.
Call 2 β toolResult:
messages.append(assistant_message) # Call 1's assistant message
messages.append({
"role": "user",
"content": [{"toolResult": {"toolUseId": "...", "content": [{"json": result}], "status": "success"}}]
})
response2 = client.converse(modelId=..., messages=messages, toolConfig=...)
Call 2 returns stopReason="end_turn" with the natural language answer.
Q3 The model returned a content list where index [0] is a text block and index [1] is the toolUse block. What is that text block and how must your code handle it?
Amazon Nova Micro (and other chain-of-thought models) emits a <thinking>...</thinking> text block before the toolUse block when using tool calling. This is the model’s internal reasoning β it is not the final answer.
The critical implementation rule: never assume content[0] is the toolUse block. Iterate the entire content array and filter for blocks that contain the "toolUse" key:
tool_use_blocks = []
for block in assistant_message.get("content", []):
if "toolUse" in block:
tool_use_blocks.append(block["toolUse"])
If you index content[0] directly expecting a toolUse block, your code will fail with Nova Micro on every request where the thinking block appears (which is most of them).
Q4 What is toolUseId and why must it match in the toolResult?
toolUseId is the correlation ID the model assigns to each tool call request. It’s a unique string like "tooluse_abc123" that the model generates in Call 1’s response.
When the app sends back the tool result in Call 2, it must include the same toolUseId:
{
"toolResult": {
"toolUseId": "tooluse_abc123", # must match the Call 1 value exactly
"content": [{"json": {"balance": 4823.50, ...}}],
"status": "success"
}
}
The model uses the ID to match each result back to the tool call that requested it. If the IDs don’t match, the model cannot correlate results to requests β critical when parallel tool calls return multiple results in one Call 2.
Q5 Can the model call multiple tools in one response? How does your code handle it?
Yes β this is called parallel tool calling. Call 1 can return multiple toolUse blocks in the same content list. The app must execute all of them and send all results back in a single Call 2 user message:
tool_result_content = []
for tool_use in tool_use_blocks: # iterate all toolUse blocks
result = run_tool(tool_use["name"], tool_use["input"])
tool_result_content.append({
"toolResult": {
"toolUseId": tool_use["toolUseId"], # matched by ID
"content": [{"json": result}],
"status": "success"
}
})
# One Call 2 with ALL results
messages.append({"role": "user", "content": tool_result_content})
Never send one Call 2 per tool result β that would fragment the conversation history and produce incorrect model behaviour.
Q6 When would you use Bedrock Agents instead of toolConfig on the Converse API?
Use toolConfig (this pattern) when:
- You need control over the tool-calling loop β custom retry logic, error handling, audit trail
- Single-step or simple tool calls
- You already have Lambda functions or existing API endpoints as tools
- Cost per call matters (Bedrock Agents has per-step pricing on top of model inference)
Use Bedrock Agents when:
- You need multi-step agentic workflows where the model must reason about what to do next across many steps
- You want a fully managed orchestration layer (Bedrock handles the tool-use loop)
- You need a built-in action group library (DynamoDB, S3, Lambda, OpenAPI specs)
- You’re building a production agent that needs session memory, knowledge bases, and guardrails wired together
The key trade-off: toolConfig gives you control; Bedrock Agents gives you a managed orchestration platform. For a single “look up live balance” use case, toolConfig is simpler and cheaper.
Q7 Call 2 came back with stopReason='tool_use' again. What happened and what does your loop do?
Sequential tool use β the model needed a second tool after receiving the first result. This is the most common tool-calling interview trap: code that assumes Call 2 always returns end_turn will extract zero text blocks and return answer: "" with a clean-looking audit trail. Silent wrong answer, no error raised.
The correct implementation is a bounded loop:
while round_num < MAX_TOOL_ROUNDS:
response = client.converse(...)
if response["stopReason"] == "end_turn":
return extract_answer(response)
if response["stopReason"] == "tool_use":
# execute tools, append toolResult, loop again
round_num += 1
return safe_fallback_answer # budget exhausted
MAX_TOOL_ROUNDS = 3 in this pattern. After exhausting rounds, return a fallback and log the full audit trail β never retry indefinitely.
Q8 The get_balance tool raised an exception. What do you send back to the model, and why not just propagate the error?
Send toolResult with status: "error" and the error message in content:
try:
result = run_tool(name, tool_input)
status = "success"
except Exception as exc:
result = {"error": str(exc)}
status = "error"
{"toolResult": {"toolUseId": tool_use_id, "content": [{"json": result}], "status": status}}
The model receives the error description and responds with a graceful apology β “I wasn’t able to retrieve your account information.” The customer sees a sensible message. The audit trail records the error stage.
Propagating the exception as a 500 breaks the two-call cycle and leaves the conversation in an inconsistent message history state. Silently returning a blank toolResult is worse β the model receives nothing to work with and generates a confusing non-answer. status: "error" is the API’s designed error path: use it.
Q9 What is the cost of the two-call cycle for tool calling?
Two Nova Micro Converse calls per request:
- Call 1 (question + toolConfig): ~200 input tokens + ~50 output tokens
- Call 2 (toolResult + history): ~300 input tokens (includes tool result JSON) + ~80 output tokens
Nova Micro pricing: $0.035/1M input + $0.14/1M output tokens.
Rough per-request cost: ~500 input tokens + ~130 output tokens = $0.035/1M Γ 0.0005 + $0.14/1M Γ 0.00013 β **$0.00006/request**.
At 10,000 requests/day: ~$0.60/day. For live balance lookups in a payment-support context, this is effectively free β but each tool call adds one extra Converse call compared to P01-P04’s single call. The cost of live data accuracy is one extra model call.
Q10 You're running this at 10,000 requests per minute and every request potentially triggers a 2-4 round tool-calling loop. What actually breaks first at that scale, and how do you fix it?
Three things break in a specific order as volume climbs, and they’re not the same fix:
- The tool backend, not Bedrock. Each round hits a real system β an account API, a Lambda, a database. At 10k rpm with an average of 2 tool calls per request, that’s 20,000 downstream calls/minute against whatever
get_balanceactually calls. Bedrock scales; your account-lookup service usually doesn’t without its own capacity plan (read replicas, caching layer, its own quota increase). MAX_TOOL_ROUNDSbecomes a real cost lever, not just a safety net. At low volume, a bounded loop of 3 rounds is just correctness hygiene. At high volume, every extra round is a full extra Converse call multiplied by 10,000 β tightening the bound (or fixing why the model needs 3 rounds instead of 1) is a direct cost lever once you’re at scale.- Provisioned Throughput becomes necessary, not optional β on-demand Bedrock has per-account RPM limits, and tool calling’s two-plus-call pattern hits that ceiling faster per “request” than a single-call pattern like Pattern 01-04.
The trap to name explicitly: teams estimate tool-calling cost as “1 request = 1 model call” and get surprised at scale, because it’s actually 2-4 model calls plus N downstream tool calls per request β the multiplier compounds in both directions, not just Bedrock’s.
Q11 How do you explain tool calling to a product manager?
“In the previous patterns, before every customer question we printed out Mike’s account information and handed it to the AI. The AI answered based on that printout. If the printout was yesterday’s data, the AI answered from yesterday’s data.
In Pattern 05, the AI has a phone. When Mike asks ‘What’s my balance?’, the AI calls the bank’s account system itself, gets today’s real balance, and then answers. We didn’t print anything out and hand it over β the AI requested what it needed, live, at the moment of the question.
The customer gets today’s answer, not yesterday’s.”
Technical summary: Bedrock Converse toolConfig parameter. Model signals tool use with stopReason="tool_use". App executes the tool, sends toolResult. Model composes final answer from live data. Two model calls per request instead of one.
Have questions or feedback? Drop a comment below or connect on LinkedIn.
π¬ Comments