This is one of the hands-on labs that run alongside these posts. The scaffolding keeps fading; this one hands you the tool and the backend and asks you to build the loop that connects them. The full lab is in lab-06-tool-use-loop.zip.
Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours.
The scenario
A support assistant is asked “where is my order GB-1001?”. The answer lives in an orders backend the model cannot reach, but the request advertises a tool, get_delivery_status, that can look it up. The response is a tool call rather than a guess: stopReason set to tool_use, and a toolUse block naming the tool and its arguments. You run the tool, hand the result back, and the model produces the answer from it. Building that loop by hand is how a managed agent stops being magic.
What you’re given
A Lambda that can call Bedrock, the tool schema, a mock orders backend (_ORDERS), the _execute_tool() function that reads it, and the first Converse call with the tool attached. The model is Nova Lite (amazon.nova-lite-v1:0). It supports client-side tool calling through Converse, and the stack takes a cross-Region inference profile id such as us.amazon.nova-lite-v1:0 if in-Region on-demand capacity is short. The gap is _resolve(), the loop that runs when a response carries that stop reason.
Your task
While the model’s stopReason is tool_use, run the tool and continue the conversation. Each pass of the loop appends the assistant’s message from the response to messages, runs _execute_tool() for every content block that carries a toolUse, and appends one user message whose content is the matching toolResult blocks, each echoing its toolUseId. Then call _bedrock.converse again with the grown messages and the same toolConfig, and reassign response so the loop can end. When the stop reason changes to end_turn, the answer is the text of the last content block in the model’s message; the module docstring spells out the exact shapes the API expects.
Deploy and prove it
cd lab-06-tool-use-loop
./scripts/deploy.sh
./scripts/test.sh
./scripts/teardown.sh
“Where is my order GB-1001?” comes back with the delivery status that only exists in the backend the tool read, and GB-9999 returns a plain not-found. The model never saw _ORDERS; it requested the lookup, and your code supplied the answer. The third question in the test has nothing to do with orders, and its first response comes straight back as end_turn with no toolUse block at all: a tool in the request is an option, not an instruction.
When you want the reference answer, deploy it with SRC=solution ./scripts/deploy.sh, or unfold it here:
Show the answer
def _resolve(response, messages):
while response["stopReason"] == "tool_use":
assistant_msg = response["output"]["message"]
messages.append(assistant_msg)
tool_results = []
for block in assistant_msg["content"]:
if "toolUse" in block:
use = block["toolUse"]
result = _execute_tool(use["name"], use.get("input"))
tool_results.append({"toolResult": {
"toolUseId": use["toolUseId"],
"content": [{"text": result}],
}})
messages.append({"role": "user", "content": tool_results})
response = _bedrock.converse(
modelId=MODEL_ID, messages=messages,
toolConfig={"tools": [TOOL]},
inferenceConfig={"maxTokens": 512, "temperature": 0.2},
)
return response["output"]["message"]["content"][-1]["text"]
The ideas that carry over
- Tool use runs over several calls. The model returns
stopReason: tool_use, you send back atoolResult, and generation continues. One response can carry severaltoolUseblocks, and a turn can go round the loop more than once before the final answer. Any scenario where a model needs live data or an action is this loop. - The model never touches your systems. It emits a request; your code holds the credentials and does the work. That is why the tool’s execution (the Lambda or service behind a real agent tool) is where you enforce least privilege, and why a coaxed tool call cannot exceed what that code is allowed to do. Converse is always client-side in this sense. Bedrock’s server-side tool calling, on the Responses API, invokes a Lambda or an AgentCore Gateway for you under the calling application’s own IAM policy.
- A managed agent automates this loop, and adds orchestration, memory, and knowledge-base lookups. Amazon Bedrock Agents is now Bedrock Agents Classic. It went into maintenance mode on 30 July 2026, closed to accounts with no prior use, so do not pick it for new work. Amazon Bedrock AgentCore is the replacement, and its Gateway wraps Lambda functions and REST APIs as MCP tools. That is a different wire format from
toolSpec, over the same request-run-return contract. - The
toolUseIdstitches request to result. Keep it exact and keep the message order right (assistanttoolUse, then ausertoolResult), or the next call fails validation.
What’s worth remembering
- Tool use is a multi-step exchange: the model returns a tool call, your code runs the tool, the result goes back, generation continues.
- The model only ever proposes a call; your code executes it, so the execution role on that code bounds the blast radius.
- Match every
toolResultto itstoolUsebytoolUseId, and keep the assistant-then-user message order, or Converse returns aValidationException. - Reassign the response inside the loop, or
stopReasonnever changes and the loop never ends. - A managed agent runs this same loop plus orchestration and memory, and AgentCore Gateway publishes the same contract as MCP tools rather than Converse
toolSpecblocks. - Tool use is how a model reaches live data or takes an action; facts pasted into a prompt are frozen at the moment you wrote it.