{"slug": "aie-2-5-building-it-support-assistant", "title": "aie_2.5: building it (Support Assistant)", "summary": "A developer has published a build walkthrough for \"Support Assistant,\" a Python script that combines structured outputs, tool use, and streaming in a single Anthropic Claude-powered customer support workflow. The script files an incoming customer message into a support log, looks up an order in a local dictionary standing in for a database, and streams the reply back to the user.", "body_md": "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.\n\nIn 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.\n\nIn this build, we write all three in one script. We’ll call what we build *Support Assistant*.\n\n### What we are building\n\nA 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.\n\n### What you need\n\n- A terminal\n- Python installed on your machine\n- An Anthropic API key ( [platform.claude.com](https://platform.claude.com) , you will need to add a small credit balance as the free tier does not cover API access)\n- A code editor (VS Code is fine if you do not have a preference)\n\n### Setting up\n\nIf 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.\n\n```\nmkdir ai-engineering\ncd ai-engineering\npython3 -m venv venv\nsource venv/bin/activate\n```\n\nYou will know it worked when you see `(venv)` at the start of your terminal line.\n\nInstall the two libraries you need:\n\n```\npip install anthropic python-dotenv\n```\n\n- `anthropic` is the official Python library for talking to Claude.\n- `python-dotenv` reads your API key from a file so you never have to hardcode it in your script.\n\nCreate a `.env` file and add your key:\n\n```\nANTHROPIC_API_KEY=your_key_here\n```\n\nCreate the script file:\n\n```\nmkdir 02\ntouch 02/support_assistant.py\n```\n\nOpen `02/support_assistant.py` in your editor. This is where you will build the script.\n\n### Step 1: set up and add the orders data\n\nStart with the same three lines from the last build, plus one new import.\n\n``` python\nfrom dotenv import load_dotenv\nimport os\nimport json\nfrom anthropic import Anthropic\n\nload_dotenv()\n\nclient = Anthropic(api_key=os.getenv(\"ANTHROPIC_API_KEY\"))\n```\n\n- `load_dotenv()` reads your`.env` file and makes everything inside it available to the script.\n- `import os` gives the script access to environment variables, the place where your API key lives after`load_dotenv()` runs.\n- `import json` is 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.\n- `Anthropic(api_key=...)` creates the client, the connection every call goes through.\n\nNow 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.\n\n```\nORDERS = {\n    \"3310\": {\"customer\": \"Jonah\", \"status\": \"delivered\", \"delivery_date\": \"2026-09-14\"},\n    \"4790\": {\"customer\": \"Amara\", \"status\": \"returned\", \"delivery_date\": \"2026-09-11\"},\n    \"4821\": {\"customer\": \"Amara\", \"status\": \"delayed at courier\", \"delivery_date\": \"2026-09-24\"},\n    \"5127\": {\"customer\": \"Priya\", \"status\": \"preparing\", \"delivery_date\": \"2026-09-30\"},\n}\n```\n\n- Each order number is a key, and the details for that order are its value.\n- `ORDERS[\"4821\"]` gives you back the dictionary with Amara’s order in it.\n- The capital letters are a Python convention for values that stay fixed while the script runs.\n\nAnd the message we are processing:\n\n```\ncustomer_message = \"Hi, this is Amara. My order 4821 was due last week and I'm still waiting. Where is it?\"\n```\n\n## Step 2: file the message with a schema\n\nOur 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.\n\n```\nrecord_schema = {\n    \"type\": \"object\",\n    \"properties\": {\n        \"name\": {\"type\": \"string\"},\n        \"order_id\": {\"type\": \"string\"},\n        \"request\": {\"type\": \"string\"},\n    },\n    \"required\": [\"name\", \"order_id\", \"request\"],\n    \"additionalProperties\": False,\n}\n```\n\n- `properties` lists the three boxes, each one holding text.\n- `required` says all three specified fields must be filled in.\n- `additionalProperties: False` means the reply holds these three and nothing else.\n\nNow the call. It is the same `client.messages.create` from the last build, with one new parameter.\n\n```\nrecord_response = client.messages.create(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    messages=[\n        {\"role\": \"user\", \"content\": f\"Turn this customer message into a support record: {customer_message}\"}\n    ],\n    output_config={\n        \"format\": {\n            \"type\": \"json_schema\",\n            \"schema\": record_schema,\n        }\n    },\n)\n```\n\n- `output_config` is where the schema goes. It tells the API the reply must be JSON in that exact shape.\n- The `f` before the string lets you drop`customer_message` into the middle of it, which is called an f-string.\n\nRead the reply:\n\n```\nrecord = json.loads(record_response.content[0].text)\n```\n\n- `record_response.content[0].text` pulls the words out of the first content block, the same as the last build.\n- `json.loads` turns that JSON text into a Python dictionary, so`record[\"order_id\"]` gives you`4821` .\n\nPrint it to see what you filed:\n\n```\nprint(\"Filed to support log:\")\nprint(f\"{record['name']} | {record['order_id']} | {record['request']}\")\nprint()\n```\n\nRun the script:\n\n```\npython3 02/support_assistant.py\n```\n\nYou should see:\n\n```\nFiled to support log:\nAmara | 4821 | order status\n```\n\nAnd that’s the first job done.\n\n### Step 3: describe the lookup function\n\nJob two, we want to get the model connected to the orders data.\n\nFirst the function that does the lookup:\n\n``` python\ndef get_order_status(order_id):\n    order = ORDERS.get(order_id)\n    if order is None:\n        return {\"error\": \"No order found with that number.\"}\n    return {\n        \"order_id\": order_id,\n        \"status\": order[\"status\"],\n        \"delivery_date\": order[\"delivery_date\"],\n    }\n```\n\n- `ORDERS.get(order_id)` looks up the order number in the dictionary from Step 1.\n- `.get` returns`None` when the number is missing, in place of crashing, which is why the`if` line can catch it.\n- When the order exists, the function returns its status and delivery date.\n\nTry it on its own:\n\n```\nprint(get_order_status(\"4821\"))\n{'order_id': '4821', 'status': 'delayed at courier', 'delivery_date': '2026-09-24'}\n```\n\nThat line was just to check the function works, so you can delete it before moving on.\n\nNow 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.\n\n```\ntools = [\n    {\n        \"name\": \"get_order_status\",\n        \"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.\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"order_id\": {\"type\": \"string\", \"description\": \"The order number, for example 4821\"}\n            },\n            \"required\": [\"order_id\"],\n        },\n    }\n]\n```\n\n- `name` matches the real function so your code knows which one to run when a request comes back.\n- `description` is 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.\n- `input_schema` is a schema again, the same kind of form from Step 2. Here it describes the inputs rather than the answer.\n\n## Step 4: make the call and read the tool request\n\nNow send the message with the tool list attached.\n\n```\nmessages = [\n    {\"role\": \"user\", \"content\": customer_message}\n]\n\nresponse = client.messages.create(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    tools=tools,\n    messages=messages,\n)\n```\n\n- `messages` is the conversation, starting with Amara’s message. It is a variable here because it grows in the next step.\n- `tools` is the new parameter, handing the model the description you wrote in Step 3.\n\nCheck what came back:\n\n```\nprint(response.stop_reason)\ntool_use\n```\n\n- `end_turn` would mean the model finished its answer.\n- `tool_use` means it stopped partway to ask for a function.\n\nThe request itself is in the content blocks. In the last build `content` held one block of text, and this time it holds two.\n\n```\nfor block in response.content:\n    print(block.type)\ntext\ntool_use\n```\n\nPull out the second one:\n\n```\ntool_request = next(block for block in response.content if block.type == \"tool_use\")\n\nprint(tool_request.name)\nprint(tool_request.input)\nget_order_status\n{'order_id': '4821'}\n```\n\n- `next(...)` walks through the blocks and hands back the first one whose`type` is`tool_use` , then stops.\n- `tool_request.name` is the function the model wants.\n- `tool_request.input` holds what it filled into the form, with the order number it read out of Amara’s message.\n\nThose two `print` lines were checks, so delete them before the next step.\n\n## Step 5: run the function and send the result back\n\nThe model asked for the function and your code is what runs it.\n\n```\norder_id = tool_request.input[\"order_id\"]\n\nprint(f\"Looking up order {order_id}...\")\nprint()\n\nresult = get_order_status(order_id)\n```\n\n- `tool_request.input[\"order_id\"]` pulls the order number out of the request.\n- `get_order_status(order_id)` is the function from Step 3, running against the orders data.\n- `result` now holds the status and delivery date.\n\nThe 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.\n\n```\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n\nmessages.append({\n    \"role\": \"user\",\n    \"content\": [\n        {\n            \"type\": \"tool_result\",\n            \"tool_use_id\": tool_request.id,\n            \"content\": json.dumps(result),\n        }\n    ],\n})\n```\n\n- 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.\n- The second carries the result in a `tool_result` block.\n- `tool_use_id` is the label from the request, so the model knows which request this result answers.\n- `json.dumps(result)` is the reverse of`json.loads` . It turns the Python dictionary back into JSON text so it can travel inside a message.\n\nYour conversation now looks like this:\n\n```\n1. user       Amara's message\n2. assistant  the model's text, plus its request for get_order_status\n3. user       the tool_result holding the order details\n```\n\nWe’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.\n\n## Step 6: stream the answer\n\nTime for the third job. The second call is where the answer comes from, so that is the one to stream.\n\nAn ordinary call would look like this:\n\n```\nfinal = client.messages.create(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    tools=tools,\n    messages=messages,\n)\n```\n\nThe library has a second method that takes the same parameters. Swap `create` for `stream`:\n\n```\nwith client.messages.stream(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    tools=tools,\n    messages=messages,\n) as stream:\n    for text in stream.text_stream:\n        print(text, end=\"\", flush=True)\n\n    final = stream.get_final_message()\n\nprint()\n```\n\n- `with` keeps the connection open while the pieces arrive and closes it once the indented part finishes, whether the code ran cleanly or hit an error.\n- `as stream` names the open connection so you can reach it.\n- `stream.text_stream` hands you each piece of text as it arrives, and the`for` loop runs once per piece.\n- `end=\"\"` stops`print` adding a new line after every piece, so they join into sentences.\n- `flush=True` puts each piece on the screen immediately, in place of Python holding them back in batches.\n- `get_final_message()` runs once the model stops writing and hands back the complete response object, the same one from the last build.\n\nThe counts are on `final`, like before:\n\n```\nprint()\nprint(f\"Input tokens: {final.usage.input_tokens}\")\nprint(f\"Output tokens: {final.usage.output_tokens}\")\n```\n\n## Your final code\n\n``` python\nfrom dotenv import load_dotenv\nimport os\nimport json\nfrom anthropic import Anthropic\n\nload_dotenv()\n\nclient = Anthropic(api_key=os.getenv(\"ANTHROPIC_API_KEY\"))\n\nORDERS = {\n    \"3310\": {\"customer\": \"Jonah\", \"status\": \"delivered\", \"delivery_date\": \"2026-09-14\"},\n    \"4790\": {\"customer\": \"Amara\", \"status\": \"returned\", \"delivery_date\": \"2026-09-11\"},\n    \"4821\": {\"customer\": \"Amara\", \"status\": \"delayed at courier\", \"delivery_date\": \"2026-09-24\"},\n    \"5127\": {\"customer\": \"Priya\", \"status\": \"preparing\", \"delivery_date\": \"2026-09-30\"},\n}\n\ncustomer_message = \"Hi, this is Amara. My order 4821 was due last week and I'm still waiting. Where is it?\"\n\ndef get_order_status(order_id):\n    order = ORDERS.get(order_id)\n    if order is None:\n        return {\"error\": \"No order found with that number.\"}\n    return {\n        \"order_id\": order_id,\n        \"status\": order[\"status\"],\n        \"delivery_date\": order[\"delivery_date\"],\n    }\n\nrecord_schema = {\n    \"type\": \"object\",\n    \"properties\": {\n        \"name\": {\"type\": \"string\"},\n        \"order_id\": {\"type\": \"string\"},\n        \"request\": {\"type\": \"string\"},\n    },\n    \"required\": [\"name\", \"order_id\", \"request\"],\n    \"additionalProperties\": False,\n}\n\ntools = [\n    {\n        \"name\": \"get_order_status\",\n        \"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.\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"order_id\": {\"type\": \"string\", \"description\": \"The order number, for example 4821\"}\n            },\n            \"required\": [\"order_id\"],\n        },\n    }\n]\n\nrecord_response = client.messages.create(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    messages=[\n        {\"role\": \"user\", \"content\": f\"Turn this customer message into a support record: {customer_message}\"}\n    ],\n    output_config={\n        \"format\": {\n            \"type\": \"json_schema\",\n            \"schema\": record_schema,\n        }\n    },\n)\n\nrecord = json.loads(record_response.content[0].text)\n\nprint(\"Filed to support log:\")\nprint(f\"{record['name']} | {record['order_id']} | {record['request']}\")\nprint()\n\nmessages = [\n    {\"role\": \"user\", \"content\": customer_message}\n]\n\nresponse = client.messages.create(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    tools=tools,\n    messages=messages,\n)\n\ntool_request = next(block for block in response.content if block.type == \"tool_use\")\n\norder_id = tool_request.input[\"order_id\"]\n\nprint(f\"Looking up order {order_id}...\")\nprint()\n\nresult = get_order_status(order_id)\n\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n\nmessages.append({\n    \"role\": \"user\",\n    \"content\": [\n        {\n            \"type\": \"tool_result\",\n            \"tool_use_id\": tool_request.id,\n            \"content\": json.dumps(result),\n        }\n    ],\n})\n\nwith client.messages.stream(\n    model=\"claude-haiku-4-5\",\n    max_tokens=1024,\n    tools=tools,\n    messages=messages,\n) as stream:\n    for text in stream.text_stream:\n        print(text, end=\"\", flush=True)\n\n    final = stream.get_final_message()\n\nprint()\nprint()\nprint(f\"Input tokens: {final.usage.input_tokens}\")\nprint(f\"Output tokens: {final.usage.output_tokens}\")\n```\n\n## Run it\n\n```\npython3 02/support_assistant.py\n```\n\nYou should see something like this:\n\n```\nFiled to support log:\nAmara | 4821 | order status\n\nLooking up order 4821...\n\nYour order 4821 is currently delayed at the courier. The updated delivery\ndate is September 24. Sorry for the wait, let me know if there's anything\nelse I can help with.\n\nInput tokens: 412\nOutput tokens: 38\n```\n\nThe answer shows up word by word, which the we cannot simulate here but run it to see that part.\n\n## What you are seeing\n\nThree calls went out in that run, and each one did a different job.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\n## Try your own messages\n\nChange `customer_message` and run it again.\n\n```\ncustomer_message = \"Hi, it's Priya. Any update on 5127?\"\n```\n\nThen try one with an order number that is missing from `ORDERS`:\n\n```\ncustomer_message = \"Where is order 9999?\"\n```\n\nThe 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.\n\nThat 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!", "url": "https://wpnews.pro/news/aie-2-5-building-it-support-assistant", "canonical_source": "https://heymeraki.substack.com/p/aie_25-building-it-support-assistant", "published_at": "2026-10-07 15:02:05+00:00", "updated_at": "2026-10-07 15:19:13.083801+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "ai-agents", "developer-tools"], "entities": ["Anthropic", "Claude", "Kora Home", "Support Assistant", "Amara"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/aie-2-5-building-it-support-assistant", "markdown": "https://wpnews.pro/news/aie-2-5-building-it-support-assistant.md", "text": "https://wpnews.pro/news/aie-2-5-building-it-support-assistant.txt", "jsonld": "https://wpnews.pro/news/aie-2-5-building-it-support-assistant.jsonld"}}