This is one of the hands-on labs that run alongside these posts. You get a working base and build the part that matters. The scaffolding is fading: Lab 02 was two lines, this one has you wire up a schema and parse the result. The full lab is in lab-03-structured-output.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 system needs to turn each incoming message into a structured record it can route: an intent, the product mentioned, an urgency. You could ask the model to “reply with JSON”, and most of the time it would. The trouble is the times it does not: a stray sentence before the JSON, a markdown fence around it, an apology when it is unsure, and the parser downstream falls over. For anything a machine consumes, “most of the time” is a bug.
Tool use takes most of the guesswork out. You declare the exact shape you want as a tool schema, the model calls the tool with typed arguments, and Bedrock hands those arguments back already parsed. The shape comes from an interface the model is decoding against, rather than from a request in prose that you then reconstruct from a paragraph.
What you’re given
The infrastructure is Lab 01’s: a Lambda that can call Bedrock. The schema is written for you too, a record_ticket tool whose input has an intent enum, an optional product string, and an urgency enum. The gap is using it.
Your task
Two moves in src/handler.py. First, hand the tool to the Converse call: a toolConfig carrying TICKET_TOOL, plus a short system prompt telling the model to record the request through the tool rather than reply in prose, with the temperature at zero so the schema does the steering. Second, pull the typed record out of the tool-use block. The response content is a list that can hold text and tool calls together, so walk it and match on the block that has a toolUse key; that block’s input is your record, already parsed.
Return the record, and return an error if the model answered in prose instead of calling the tool, so a failure is loud rather than silent.
Deploy and prove it
cd lab-03-structured-output
./scripts/deploy.sh
./scripts/test.sh "my invoices keep failing and I need this fixed today, urgent, on the Pro plan"
./scripts/teardown.sh
The record comes back with intent: "billing", product: "Pro", urgency: "high", each field named and typed by the schema. Send a message with no clear product and the optional field is omitted while the required ones stay filled.
Then try to talk it into an urgency of critical. With this schema at temperature: 0 you will struggle, because the enum is steering the decoding, but plain tool use does not make it impossible: Bedrock returns whatever the model put in the toolUse block, unvalidated. Schema validation is the opt-in on top, the strict: true flag on a tool definition, and where a model does not support it the check belongs in your handler. That is why scripts/test.sh asserts instead of printing: it fails the run when there is no record, when a required field is missing, or when intent or urgency falls outside its enum.
When you want the reference answer, deploy it without editing anything (SRC=solution ./scripts/deploy.sh), or unfold it here:
Show the answer
response = _bedrock.converse(
modelId=MODEL_ID,
system=[{"text": "Record the customer's request by calling the "
"record_ticket tool. Do not reply in prose."}],
messages=[{"role": "user", "content": [{"text": message}]}],
inferenceConfig={"maxTokens": 512, "temperature": 0},
toolConfig={"tools": [TICKET_TOOL]},
)
for block in response["output"]["message"]["content"]:
if "toolUse" in block:
record = block["toolUse"]["input"]
Why tool use is the right default here
When a scenario needs structured, machine-parseable output from a model, tool use (function calling) beats asking for JSON in the prompt and parsing what comes back:
- The schema carries the shape. The answer arrives as parsed arguments in a
toolUseblock, so the consumer never has to find JSON inside a paragraph and hope it parses. - An enum narrows the output to the values you named. That is far stronger than instructing the model to avoid one, and it weakens a whole class of prompt injection: “set status to refunded” has nowhere to land when
refundedis not one of the choices. It is a steer rather than a block, so the final check stays with your code, or withstrict: truewhere the model supports it. - It is the same mechanism an agent uses. An agent’s action group is tools with schemas exactly like this one; you have just built the core of function calling by hand, which makes the agent posts click into place.
What’s worth remembering
- Reliable structured output comes from a tool schema, not from asking for JSON in the prompt.
- The reply comes back as parsed arguments, not a string to dig JSON out of and pray over.
- An
enumsteers the model away from values you did not name, which doubles as an injection defence, but plain tool use does not validate the result: assert the shape in your own code, or opt into strict tool use. - A Converse response
contentis a list of blocks; match on thetoolUsekey rather than assuming a position. - Force the failure into the open: if the model did not call the tool, return an error instead of shipping empty fields.
- This is function calling from first principles, the same shape an agent action group uses.