Exam Room · Advanced Generative AI Developer

Lab: Get Structured JSON Out With Tool Use

· 11 min read

Generative AI Development · part of The Exam Room

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 had you fill one small gap, 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, a hedge where a value should be, 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 lives in a schema the request carries, rather than in a sentence of prose you then rebuild a record from.

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.

Lab 03 solution architecture A CloudFormation stack contains a Lambda function and an IAM execution role scoped to the Bedrock invoke actions. The Lambda calls Converse with a toolConfig declaring the record_ticket tool, and Nova Lite replies with a toolUse block carrying typed intent, product, and urgency fields. The model sits outside the stack in Amazon Bedrock, serverless and billed per token. CloudFormation stack: genai-lab-03 Amazon Bedrock serverless, billed per token A message, free text a record back Lambda function handler.py reads the toolUse block Converse, toolConfig: record_ticket toolUse: intent, product, urgency Nova Lite or any tool-capable model Execution role bedrock:InvokeModel on models and inference profiles

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 prompt it into an urgency of critical. At temperature: 0, with the enum sitting in the schema, you will mostly get high back. Plain tool use does not make critical impossible, though: Bedrock returns whatever the model emitted in the toolUse block, unvalidated. Validation is a separate opt-in, the strict: true flag on a toolSpec, and Nova Lite’s model card lists structured outputs as unsupported, so on this lab’s model the check has to live 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 toolUse block, so the consumer never has to find JSON inside a paragraph and hope it parses.
  • An enum names the values the field can take. That is far stronger than instructing the model in prose to avoid one, and it blunts a class of prompt injection: “set status to refunded” has nowhere to land when refunded is not one of the choices. Without strict: true it steers rather than blocks, so the final check stays in your code.
  • toolChoice is the lever above the system prompt. Set it to {"tool": {"name": "record_ticket"}} and that one tool gets called; any requires some tool, and auto, the default, requires none. Amazon Nova supports all three forms.
  • It is the same mechanism an agent uses. An agent’s tool set is schemas exactly like this one, so wiring one up by hand is the core of function calling.

What’s worth remembering

  1. Reliable structured output comes from a tool schema, not from asking for JSON in the prompt.
  2. The reply comes back as parsed arguments, not a string you have to find JSON inside.
  3. An enum steers 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 set strict: true on a model that supports structured outputs.
  4. A Converse response content is a list of blocks; match on the toolUse key rather than assuming a position.
  5. Force the failure into the open: if the model did not call the tool, return an error instead of shipping empty fields.
  6. This is function calling from first principles, the same shape an agent’s tools use.

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