Before going any further with this build, a quick note. This build assumes you have read aie_2.2, aie_2.3 and aie_2.4. If you have not, start there, it’ll make this make more sense.
In the last three lessons, we followed one customer message through three jobs. Amara wrote in asking where her order was. Structured outputs turned her message into a row for the support log. Tool use let the model look up order 4821 in Kora Home’s orders table. Streaming put the answer on her screen while it was still being written.
In this build, we write all three in one script. We’ll call what we build Support Assistant.
What we are building
A Python script that takes a customer message, files it into a support log, looks up the order in a table, and streams the answer back. It is the Kora Home assistant from the last three lessons, running as code you can send your own messages through.
What you need
- A terminal
- Python installed on your machine
- An Anthropic API key ( platform.claude.com , you will need to add a small credit balance as the free tier does not cover API access)
- A code editor (VS Code is fine if you do not have a preference)
Setting up
If you still have the folder from the aie_1.1 build, you can work in it and skip ahead to creating the script file. Otherwise, create a folder and set up a virtual environment. A virtual environment keeps your project’s dependencies isolated, you can think of it as a container for everything this project needs, separate from anything else on your machine.
mkdir ai-engineering
cd ai-engineering
python3 -m venv venv
source venv/bin/activate
You will know it worked when you see (venv) at the start of your terminal line.
Install the two libraries you need:
pip install anthropic python-dotenv
anthropicis the official Python library for talking to Claude.python-dotenvreads your API key from a file so you never have to hardcode it in your script.
Create a .env file and add your key:
ANTHROPIC_API_KEY=your_key_here
Create the script file:
mkdir 02
touch 02/support_assistant.py
Open 02/support_assistant.py in your editor. This is where you will build the script.
Step 1: set up and add the orders data
Start with the same three lines from the last build, plus one new import.
from dotenv import load_dotenv
import os
import json
from anthropic import Anthropic
load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
load_dotenv()reads your.envfile and makes everything inside it available to the script.import osgives the script access to environment variables, the place where your API key lives afterload_dotenv()runs.import jsonis the new one. The model’s reply comes back as JSON text, and this is what turns it into something Python can read values out of.Anthropic(api_key=...)creates the client, the connection every call goes through.
Now the orders. In a working shop this would be a database, but a database adds setup that has nothing to do with what we are learning here. A Python dictionary does the same job for this build.
ORDERS = {
"3310": {"customer": "Jonah", "status": "delivered", "delivery_date": "2026-09-14"},
"4790": {"customer": "Amara", "status": "returned", "delivery_date": "2026-09-11"},
"4821": {"customer": "Amara", "status": "delayed at courier", "delivery_date": "2026-09-24"},
"5127": {"customer": "Priya", "status": "preparing", "delivery_date": "2026-09-30"},
}
- Each order number is a key, and the details for that order are its value.
ORDERS["4821"]gives you back the dictionary with Amara’s order in it.- The capital letters are a Python convention for values that stay fixed while the script runs.
And the message we are processing:
customer_message = "Hi, this is Amara. My order 4821 was due last week and I'm still waiting. Where is it?"
Step 2: file the message with a schema #
Our first job is to turn the message into three clean values for the support log. We’ll start with the schema, the form the model has to fill in.
record_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"order_id": {"type": "string"},
"request": {"type": "string"},
},
"required": ["name", "order_id", "request"],
"additionalProperties": False,
}
propertieslists the three boxes, each one holding text.requiredsays all three specified fields must be filled in.additionalProperties: Falsemeans the reply holds these three and nothing else.
Now the call. It is the same client.messages.create from the last build, with one new parameter.
record_response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
messages=[
{"role": "user", "content": f"Turn this customer message into a support record: {customer_message}"}
],
output_config={
"format": {
"type": "json_schema",
"schema": record_schema,
}
},
)
output_configis where the schema goes. It tells the API the reply must be JSON in that exact shape.- The
fbefore the string lets you dropcustomer_messageinto the middle of it, which is called an f-string.
Read the reply:
record = json.loads(record_response.content[0].text)
record_response.content[0].textpulls the words out of the first content block, the same as the last build.json.loadsturns that JSON text into a Python dictionary, sorecord["order_id"]gives you4821.
Print it to see what you filed:
print("Filed to support log:")
print(f"{record['name']} | {record['order_id']} | {record['request']}")
print()
Run the script:
python3 02/support_assistant.py
You should see:
Filed to support log:
Amara | 4821 | order status
And that’s the first job done.
Step 3: describe the lookup function
Job two, we want to get the model connected to the orders data.
First the function that does the lookup:
def get_order_status(order_id):
order = ORDERS.get(order_id)
if order is None:
return {"error": "No order found with that number."}
return {
"order_id": order_id,
"status": order["status"],
"delivery_date": order["delivery_date"],
}
ORDERS.get(order_id)looks up the order number in the dictionary from Step 1..getreturnsNonewhen the number is missing, in place of crashing, which is why theifline can catch it.- When the order exists, the function returns its status and delivery date.
Try it on its own:
print(get_order_status("4821"))
{'order_id': '4821', 'status': 'delayed at courier', 'delivery_date': '2026-09-24'}
That line was just to check the function works, so you can delete it before moving on.
Now describe that function to the model. The model only reads text, so this is a written description of what the function does and what it needs.
tools = [
{
"name": "get_order_status",
"description": "Look up the current status and delivery date of a Kora Home order, given its order number. Use this for any question about where an order is, when it will arrive, or whether it has shipped.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order number, for example 4821"}
},
"required": ["order_id"],
},
}
]
namematches the real function so your code knows which one to run when a request comes back.descriptionis what the model reads to decide whether this function fits the message in front of it. The extra sentence covers the words customers actually use.input_schemais a schema again, the same kind of form from Step 2. Here it describes the inputs rather than the answer.
Step 4: make the call and read the tool request #
Now send the message with the tool list attached.
messages = [
{"role": "user", "content": customer_message}
]
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
messagesis the conversation, starting with Amara’s message. It is a variable here because it grows in the next step.toolsis the new parameter, handing the model the description you wrote in Step 3.
Check what came back:
print(response.stop_reason)
tool_use
end_turnwould mean the model finished its answer.tool_usemeans it stopped partway to ask for a function.
The request itself is in the content blocks. In the last build content held one block of text, and this time it holds two.
for block in response.content:
print(block.type)
text
tool_use
Pull out the second one:
tool_request = next(block for block in response.content if block.type == "tool_use")
print(tool_request.name)
print(tool_request.input)
get_order_status
{'order_id': '4821'}
next(...)walks through the blocks and hands back the first one whosetypeistool_use, then stops.tool_request.nameis the function the model wants.tool_request.inputholds what it filled into the form, with the order number it read out of Amara’s message.
Those two print lines were checks, so delete them before the next step.
Step 5: run the function and send the result back #
The model asked for the function and your code is what runs it.
order_id = tool_request.input["order_id"]
print(f"Looking up order {order_id}...")
print()
result = get_order_status(order_id)
tool_request.input["order_id"]pulls the order number out of the request.get_order_status(order_id)is the function from Step 3, running against the orders data.resultnow holds the status and delivery date.
The model forgot the first call the moment it ended, so the second call has to carry everything. Two messages go on the end of the conversation.
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": json.dumps(result),
}
],
})
- The first is the model’s own reply from Step 4, added back exactly as it came. This is how the model sees, on the second call, that it asked for order 4821.
- The second carries the result in a
tool_resultblock. tool_use_idis the label from the request, so the model knows which request this result answers.json.dumps(result)is the reverse ofjson.loads. It turns the Python dictionary back into JSON text so it can travel inside a message.
Your conversation now looks like this:
1. user Amara's message
2. assistant the model's text, plus its request for get_order_status
3. user the tool_result holding the order details
We’re done with the second job. The order details are in front of the model, and the next step is where we get the answer displayed.
Step 6: stream the answer #
Time for the third job. The second call is where the answer comes from, so that is the one to stream.
An ordinary call would look like this:
final = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
The library has a second method that takes the same parameters. Swap create for stream:
with client.messages.stream(
model="claude-haiku-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()
print()
withkeeps the connection open while the pieces arrive and closes it once the indented part finishes, whether the code ran cleanly or hit an error.as streamnames the open connection so you can reach it.stream.text_streamhands you each piece of text as it arrives, and theforloop runs once per piece.end=""stopsprintadding a new line after every piece, so they join into sentences.flush=Trueputs each piece on the screen immediately, in place of Python holding them back in batches.get_final_message()runs once the model stops writing and hands back the complete response object, the same one from the last build.
The counts are on final, like before:
print()
print(f"Input tokens: {final.usage.input_tokens}")
print(f"Output tokens: {final.usage.output_tokens}")
Your final code #
from dotenv import load_dotenv
import os
import json
from anthropic import Anthropic
load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
ORDERS = {
"3310": {"customer": "Jonah", "status": "delivered", "delivery_date": "2026-09-14"},
"4790": {"customer": "Amara", "status": "returned", "delivery_date": "2026-09-11"},
"4821": {"customer": "Amara", "status": "delayed at courier", "delivery_date": "2026-09-24"},
"5127": {"customer": "Priya", "status": "preparing", "delivery_date": "2026-09-30"},
}
customer_message = "Hi, this is Amara. My order 4821 was due last week and I'm still waiting. Where is it?"
def get_order_status(order_id):
order = ORDERS.get(order_id)
if order is None:
return {"error": "No order found with that number."}
return {
"order_id": order_id,
"status": order["status"],
"delivery_date": order["delivery_date"],
}
record_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"order_id": {"type": "string"},
"request": {"type": "string"},
},
"required": ["name", "order_id", "request"],
"additionalProperties": False,
}
tools = [
{
"name": "get_order_status",
"description": "Look up the current status and delivery date of a Kora Home order, given its order number. Use this for any question about where an order is, when it will arrive, or whether it has shipped.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order number, for example 4821"}
},
"required": ["order_id"],
},
}
]
record_response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
messages=[
{"role": "user", "content": f"Turn this customer message into a support record: {customer_message}"}
],
output_config={
"format": {
"type": "json_schema",
"schema": record_schema,
}
},
)
record = json.loads(record_response.content[0].text)
print("Filed to support log:")
print(f"{record['name']} | {record['order_id']} | {record['request']}")
print()
messages = [
{"role": "user", "content": customer_message}
]
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
tool_request = next(block for block in response.content if block.type == "tool_use")
order_id = tool_request.input["order_id"]
print(f"Looking up order {order_id}...")
print()
result = get_order_status(order_id)
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": json.dumps(result),
}
],
})
with client.messages.stream(
model="claude-haiku-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()
print()
print()
print(f"Input tokens: {final.usage.input_tokens}")
print(f"Output tokens: {final.usage.output_tokens}")
Run it #
python3 02/support_assistant.py
You should see something like this:
Filed to support log:
Amara | 4821 | order status
Looking up order 4821...
Your order 4821 is currently delayed at the courier. The updated delivery
date is September 24. Sorry for the wait, let me know if there's anything
else I can help with.
Input tokens: 412
Output tokens: 38
The answer shows up word by word, which the we cannot simulate here but run it to see that part.
What you are seeing #
Three calls went out in that run, and each one did a different job.
The first call did not see the orders data. It read Amara’s message and filled in a form, which is why record came back with the same three fields in the same places. Change the message to something scruffier, like “hey where’s my stuff, order 4821”, and the fields come back identical.
The second call is where the model asked for help. It had the orders function described to it, read 4821 out of the message, and sent back a request.
The third call is where the order details reached the model in the tool_result block, which is why September 24 shows up in the reply, pulled from the ORDERS dictionary.
The input token count tells you something too. 412 tokens went into that last call, against the 19 you sent in the aie_1.1 build. That is Amara’s message, the model’s tool request, the order details and the tool description, all travelling together because the model forgets everything between calls.
Try your own messages #
Change customer_message and run it again.
customer_message = "Hi, it's Priya. Any update on 5127?"
Then try one with an order number that is missing from ORDERS:
customer_message = "Where is order 9999?"
The function will return an error that goes back to the model in the tool_result block, and the model will something sensible to the customer.
That is structured outputs, tool use and streaming running together. In the next lesson, aie_3.0, we look at the instructions we send the model on every call, which we have been writing without much thought so far. If you have any questions about this build, let me know!