Exam Room · Advanced Generative AI Developer

Lab: Wire a Tool the Model Can Call

· 11 min read

Generative AI Development · part of The Exam Room

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.

Lab 06 solution architecture A CloudFormation stack contains a Lambda function and an IAM execution role scoped to bedrock:InvokeModel on foundation models and inference profiles. The Lambda calls Converse with the get_delivery_status tool advertised, Nova Lite returns a toolUse block, the Lambda runs the tool against a mock orders backend inside the function and sends the toolResult back, and the loop repeats while the stop reason is tool_use. The model sits outside the stack in Amazon Bedrock, serverless and billed per token. CloudFormation stack: genai-lab-06 Amazon Bedrock serverless, billed per token A question, where is GB-1001? an answer back Lambda function runs get_delivery_status against a mock backend Converse, with the tool advertised toolUse: get_delivery_status(GB-1001) toolResult: the looked-up status repeats while stopReason is tool_use Nova Lite returns a toolUse block when the tool fits Execution role bedrock:InvokeModel on foundation models and inference profiles

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 a toolResult, and generation continues. One response can carry several toolUse blocks, 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 moves the execution step into the service, on the Responses API and on the bedrock-mantle endpoint only, documented from the GPT OSS 20B and 120B models, so Nova Lite cannot use it. A Lambda tool there runs under the same IAM roles and policy as the application that called the model; an AgentCore Gateway tool is reached through a Bedrock execution role that holds bedrock-agentcore:InvokeGateway on the gateway.
  • 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 toolUseId stitches request to result. Keep it exact and keep the message order right (assistant toolUse, then a user toolResult), or the next call fails validation.

What’s worth remembering

  1. Tool use is a loop. The model returns a tool call, your code runs the tool, the result goes back, and generation continues.
  2. Your code executes the tool. The model only proposes a call, so the execution role on that code bounds the blast radius.
  3. Match results by toolUseId. Keep the assistant-then-user message order too, or Converse returns a ValidationException.
  4. Reassign response inside the loop. Otherwise stopReason never changes and the loop never ends.
  5. A managed agent runs this loop. It adds orchestration and memory; AgentCore Gateway publishes the same contract as MCP tools, not Converse toolSpec blocks.
  6. Tool use reaches live data. Prompt-pasted facts are frozen when written; tools fetch current data or take actions.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.