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 model has no idea, the answer lives in an orders backend, but it has a tool, get_delivery_status, that can look it up. So it does not guess: it asks to call the tool, you run it, you hand back the result, and it answers. Building that loop by hand is how the managed agent you read about 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 gap is _resolve(), the loop that runs when the model asks for a tool.
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 finally changes, the answer is the text of the last content block in the model’s message; the module docstring spells out the exact shapes the protocol demands.
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 an honest not-found. The model never saw _ORDERS; it asked, and you answered. The third question in the test has nothing to do with orders, and the model answers it without reaching for the tool 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 is a conversation, not one call. The model requests a tool (
stopReason: tool_use), you return atoolResult, and it continues. One turn can trigger several tool calls 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 (a Lambda in a real Action groupThe bundle of API operations a Bedrock agent is allowed to call, described by a schema so the model knows what each one does. ) is where you enforce least privilege, and why a coaxed tool call cannot exceed what that code is allowed to do.
- A managed agent automates this exact loop, and adds planning, memory, and knowledge-base lookups. You have just built the core of an action group by hand.
- The
toolUseIdstitches request to result. Keep it exact and keep the message order right (assistanttoolUse, then ausertoolResult), or the next turn is rejected.
What’s worth remembering
- Tool use is a multi-step exchange: the model asks, your code runs the tool, the result goes back, the model continues.
- The model only ever proposes a call; your code executes it, so the tool’s permissions, not the model’s, bound the blast radius.
- Match every
toolResultto itstoolUsebytoolUseId, and keep the assistant-then-user message order, or the call is rejected. - Reassign the response inside the loop, or
stopReasonnever changes and the loop never ends. - A managed Bedrock agent runs this same loop plus planning and memory; an action group is tools with schemas exactly like this.
- This is the reliable way to give a model live data or actions, far better than pasting facts into the prompt and hoping they are current.