Exam Room · Advanced GenAI

Lab: Wire a Tool the Model Can Call

August 01, 2026 · 9 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 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.

Lab 06 solution architecture A CloudFormation stack contains a Lambda function and an IAM execution role scoped to bedrock:InvokeModel. The Lambda calls Converse with the get_delivery_status tool advertised, Nova Lite replies with 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 decides when the tool is needed Execution role bedrock:InvokeModel on foundation models

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 a toolResult, 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 toolUseId stitches request to result. Keep it exact and keep the message order right (assistant toolUse, then a user toolResult), or the next turn is rejected.

What’s worth remembering

  1. Tool use is a multi-step exchange: the model asks, your code runs the tool, the result goes back, the model continues.
  2. The model only ever proposes a call; your code executes it, so the tool’s permissions, not the model’s, bound the blast radius.
  3. Match every toolResult to its toolUse by toolUseId, and keep the assistant-then-user message order, or the call is rejected.
  4. Reassign the response inside the loop, or stopReason never changes and the loop never ends.
  5. A managed Bedrock agent runs this same loop plus planning and memory; an action group is tools with schemas exactly like this.
  6. 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.

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