,

Build a Claude Haiku 5.5 Subagent Ticket Router

Sorting machine routing support ticket envelopes into bins, depicting a Claude Haiku 5.5 triage subagent

A good Claude Haiku 5.5 subagent does one narrow job, does it fast, and knows when to hand off. In this tutorial you will build exactly that on Amazon Bedrock: a ticket router that reads support tickets, labels each one with a category, urgency and confidence score, runs hundreds in parallel, caches its shared instructions, and sends the shaky ones to a bigger model. Everything runs from a laptop with Python and the AWS CLI, and you can finish it in an afternoon.

Claude Haiku 5.5 launched on Bedrock on October 7, 2026. AWS positions it as the model for subagents: routing, classification and high-volume extraction, with a larger model such as Claude Opus 5.5 handling planning. We will follow that script. If you want the architecture background first, the AWS launch post and the Bedrock model card cover the model itself.

Prerequisites

You need a few things in place before step one.

  • An AWS account with Amazon Bedrock access in a Region where your chosen inference profile can route. We use us-east-1 and the global profile in this guide.
  • The AWS CLI installed and configured with credentials that allow bedrock:InvokeModel. The Converse API uses the same invoke permission, so no special policy is needed beyond what your role already has for the model.
  • Python 3.10 or newer and a virtual environment tool.
  • Access to Claude Haiku 5.5 in your account. Check the Bedrock console model catalog if a call is denied.
  • Optional: access to a larger model for the escalation step. We read its ID from an environment variable, so you can use any model your account is allowed to invoke.
  • About 30 minutes, and a sample of support tickets. We generate fake ones, so you do not need real data.

Heads up on data. Use synthetic tickets while you learn. Real customer text belongs behind a Guardrails policy and an approved data residency profile, and we say more on that in step nine.

How to build a Claude Haiku 5.5 subagent on Bedrock

We build in ten steps. Each one produces something you can run and check before moving on. The finished program reads tickets from a file, classifies them in parallel, writes results to JSON lines, and reports token usage.

The design is deliberately dull. A thread pool hands tickets to a function. That function sends the ticket and a fixed instruction block to Claude Haiku 5.5 through the Converse API, and a validator checks what comes back. Anything the validator rejects, or anything the model itself is unsure about, goes to a second stage: a larger model if you have one configured, or a human review queue if you do not. There is no queue service, no database and no orchestration framework. That is on purpose. When a subagent is this small, every extra moving part is a place for a 2 AM page to hide.

Why Converse and not InvokeModel? Converse gives you one request shape across models, so swapping the worker or the escalation model later means changing a string, not rewriting a payload. The model card says Messages, Converse and Invoke all work on the standard runtime endpoint, so this is a convenience choice and not a limitation. Pick whichever fits your codebase, but know that Converse is the one that makes a planner-and-worker setup pleasant.

Step 1: Confirm your credentials and Region

Start by proving your CLI identity works. A surprising number of Bedrock errors are just an expired session or the wrong profile, and it is much better to find that out now than in the middle of a thread pool.

Run the identity check below. It prints your account ID and the ARN you are acting as. If it fails, fix your credentials before reading on.

aws sts get-caller-identity
aws configure get region

If the second command prints nothing, you have no default Region. We will pass the Region explicitly in code anyway, which is the safer habit, but it helps to know where the CLI thinks it lives.

Step 2: Make a project and install dependencies

Create a clean folder and a virtual environment so nothing here collides with your other projects. We need only boto3. Resist the urge to add a framework; for a single-purpose subagent, a framework is more code than the agent.

mkdir ticket-router && cd ticket-router
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade boto3
export AWS_REGION=us-east-1
export TRIAGE_MODEL_ID=global.anthropic.claude-haiku-5-5

The model ID is the global cross-region inference profile for Claude Haiku 5.5. Haiku 5.5 on the standard runtime endpoint is invoked through a profile, not an in-region ID, which is why the prefix matters. Swap global. for us., eu., au. or jp. if you must keep traffic inside a geography.

Step 3: Make your first Converse call from the CLI

Before writing Python, make one raw call. This confirms model access and shows you the real response shape, which is the thing most tutorials skip and most bugs come from.

aws bedrock-runtime converse \
  --region "$AWS_REGION" \
  --model-id "$TRIAGE_MODEL_ID" \
  --messages '[{"role":"user","content":[{"text":"Reply with the single word: ready"}]}]' \
  --inference-config '{"maxTokens":200}'

Read the JSON that comes back. Under output.message.content is a list of blocks. Under usage you will see input and output token counts. Adaptive thinking is on by default for this model, so the content list can contain a reasoning block ahead of the text block on some requests. Remember that, because our parser is built around it.

If you get an access error, open the Bedrock console, find Claude Haiku 5.5 in the model catalog, and confirm your account can use it. If you get a validation error about the model ID, check the prefix and the spelling character by character.

Step 4: Write a safe helper that parses by block type

Now the Python. Create router.py and start with one function that calls Converse and returns text plus usage. The rule is simple: never read content[0]. Join every block that has a text key.

This one habit prevents the most common production bug with reasoning models, which only appears on the harder prompts and therefore only appears in production.

import json
import os
import boto3
from botocore.config import Config

REGION = os.environ.get("AWS_REGION", "us-east-1")
MODEL_ID = os.environ.get("TRIAGE_MODEL_ID", "global.anthropic.claude-haiku-5-5")

client = boto3.client(
    "bedrock-runtime",
    region_name=REGION,
    config=Config(retries={"max_attempts": 8, "mode": "adaptive"}, read_timeout=120),
)


def call_model(model_id, system_blocks, user_text, max_tokens=300):
    resp = client.converse(
        modelId=model_id,
        system=system_blocks,
        messages=[{"role": "user", "content": [{"text": user_text}]}],
        inferenceConfig={"maxTokens": max_tokens},
    )
    blocks = resp["output"]["message"]["content"]
    text = "".join(b["text"] for b in blocks if "text" in b)
    return text, resp["usage"]

The retry config uses botocore’s adaptive mode, which backs off when Bedrock throttles you. That matters in step six, where we fire many requests at once. The read timeout is generous because a reasoning model that thinks hard can take a while.

Step 5: Define the subagent’s job in a system prompt

A subagent works best when its prompt reads like a job description with a strict output format. Ours says what to classify, what the labels mean, how to score confidence, and that the answer must be a single JSON object and nothing else.

Structured outputs are not supported for this model on the standard runtime endpoint, so we ask for JSON in the prompt and validate it ourselves in the next step. Add this to router.py. Notice the examples; they make the prompt long enough to be worth caching in step eight.

SYSTEM_PROMPT = """You are a support ticket triage subagent.
Read one ticket and return ONE JSON object, no prose, no code fences:
{"category": "...", "urgency": "...", "confidence": 0.0, "reason": "..."}

category must be exactly one of: billing, bug, access, feature_request, other.
urgency must be exactly one of: low, normal, high, critical.
confidence is your certainty in the category, from 0.0 to 1.0.
reason is one short sentence.

Rules:
- Anything mentioning an outage, data loss or a security issue is critical.
- Refund, invoice and charge questions are billing.
- Login, password and permission problems are access.
- If the ticket is vague or mixes topics, lower your confidence.

Examples:
Ticket: "I was charged twice this month"
{"category": "billing", "urgency": "normal", "confidence": 0.95, "reason": "Duplicate charge."}
Ticket: "Whole site is down for all our users"
{"category": "bug", "urgency": "critical", "confidence": 0.9, "reason": "Outage affecting all users."}
Ticket: "Can you add dark mode"
{"category": "feature_request", "urgency": "low", "confidence": 0.97, "reason": "Requests a new feature."}
"""

Keep that prompt boring and exact. A subagent is not the place for personality.

Step 6: Parse and validate the answer

Models occasionally wrap JSON in code fences or add a sentence. Your code should cope without crashing, and it should never trust a label it has not checked against the allowed set. Add a validator that extracts the first JSON object, checks the fields, and marks anything invalid for escalation instead of silently guessing.

CATEGORIES = {"billing", "bug", "access", "feature_request", "other"}
URGENCIES = {"low", "normal", "high", "critical"}


def parse_result(text):
    start, end = text.find("{"), text.rfind("}")
    if start == -1 or end == -1:
        return None
    try:
        data = json.loads(text[start:end + 1])
    except json.JSONDecodeError:
        return None
    if data.get("category") not in CATEGORIES:
        return None
    if data.get("urgency") not in URGENCIES:
        return None
    try:
        data["confidence"] = float(data.get("confidence", 0))
    except (TypeError, ValueError):
        return None
    return data

Returning None is the signal that something went wrong. The orchestrator in step seven treats it as a reason to escalate, which is the safe default for a router.

Step 7: Fan out in parallel with a thread pool

This is where a small, fast model earns its place. Each ticket is independent, so we classify them concurrently. A thread pool is plenty here because the work is network-bound, and boto3 clients are safe to share across threads.

Keep the worker count modest. Bedrock enforces quotas that depend on your account, and the adaptive retry mode from step four will slow you down if you push too hard, but pushing too hard is still rude. Start at eight workers and tune from there.

from concurrent.futures import ThreadPoolExecutor

SYSTEM_BLOCKS = [{"text": SYSTEM_PROMPT}]


def triage(ticket):
    text, usage = call_model(MODEL_ID, SYSTEM_BLOCKS, 'Ticket: "%s"' % ticket["body"])
    result = parse_result(text)
    return {"id": ticket["id"], "result": result, "raw": text, "usage": usage}


def run_all(tickets, workers=8):
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(triage, tickets))

pool.map preserves input order, which makes comparing against your source file easy. If one ticket raises after retries are exhausted, the exception surfaces when you read the results, so wrap triage in a try block if you prefer to record failures and keep going.

Row of small robots processing tickets in parallel at a workbench, representing parallel Haiku subagent calls

Step 8: Cache the shared prompt

Every ticket sends the same long system prompt. That repeated prefix is exactly what prompt caching is for. The model card lists explicit caching with a minimum of 512 tokens per checkpoint, up to four checkpoints per request, and a TTL of 5 minutes or 1 hour.

In Converse, you place a cache point after the content you want cached. Replace the system blocks with this version. If your prompt is shorter than the minimum, pad it with more examples; that also tends to improve accuracy.

SYSTEM_BLOCKS = [
    {"text": SYSTEM_PROMPT},
    {"cachePoint": {"type": "default"}},
]

To verify it works, run two tickets one after the other and print the usage dict. Look for cache read and cache write counters in usage. The first call should write, and the second should read. If both show zero, something in your prefix changes between calls; a timestamp or request ID in the system prompt is the usual cause. Fix that before you scale up, because a cache that never hits is just a longer prompt.

Step 9: Escalate low-confidence tickets to a bigger model

A router earns trust by knowing its limits. Any ticket where the result is invalid, the confidence is below a threshold, or the urgency is critical gets a second opinion from a larger model. We read that model’s ID from an environment variable so the code does not care which one you pick.

Set ESCALATION_MODEL_ID to the ID or inference profile of a larger model your account can invoke, copied from the Bedrock model catalog rather than from memory. If you leave it unset, flagged tickets are simply marked for human review, which is also a perfectly respectable outcome.

ESCALATION_MODEL_ID = os.environ.get("ESCALATION_MODEL_ID")
CONFIDENCE_FLOOR = 0.8


def needs_escalation(item):
    r = item["result"]
    return r is None or r["confidence"] < CONFIDENCE_FLOOR or r["urgency"] == "critical"


def escalate(item, ticket_body):
    if not ESCALATION_MODEL_ID:
        item["status"] = "human_review"
        return item
    text, usage = call_model(
        ESCALATION_MODEL_ID, [{"text": SYSTEM_PROMPT}], 'Ticket: "%s"' % ticket_body
    )
    item["result"] = parse_result(text)
    item["escalated_usage"] = usage
    item["status"] = "escalated" if item["result"] else "human_review"
    return item

Two safety notes. First, when real customer data is involved, put a Bedrock Guardrails policy in front of both models; Guardrails are supported with this model on the standard runtime endpoint. Second, keep your data residency rules in mind and pick a geographic profile instead of the global one if your policy demands it. The Converse API guide documents the request fields used here, including guardrail configuration.

Step 10: Wire up the main program and read the numbers

Finish with a main block that loads tickets, runs the fleet, escalates the flagged ones, writes JSON lines, and prints a summary. The summary matters: it is where you see output tokens, which is what you pay for, including any reasoning the model did.

First create a small synthetic ticket file so the program has something to chew on.

python3 - <<'PY'
import json
samples = [
    "I was charged twice for my subscription",
    "The dashboard is down for our whole team",
    "I cannot log in after resetting my password",
    "Please add CSV export to reports",
    "Hello, question about stuff",
]
with open("tickets.jsonl", "w") as f:
    for i in range(40):
        f.write(json.dumps({"id": i, "body": samples[i % len(samples)]}) + "\n")
PY

Now append the main section to router.py and run it.

if __name__ == "__main__":
    tickets = [json.loads(line) for line in open("tickets.jsonl")]
    bodies = {t["id"]: t["body"] for t in tickets}
    items = run_all(tickets)

    for item in items:
        item["status"] = "ok"
        if needs_escalation(item):
            escalate(item, bodies[item["id"]])

    with open("results.jsonl", "w") as f:
        for item in items:
            f.write(json.dumps(item) + "\n")

    total_in = sum(i["usage"]["inputTokens"] for i in items)
    total_out = sum(i["usage"]["outputTokens"] for i in items)
    flagged = sum(1 for i in items if i["status"] != "ok")
    print("tickets:", len(items), "flagged:", flagged)
    print("input tokens:", total_in, "output tokens:", total_out)
python3 router.py
head -n 3 results.jsonl

Read the output. The vague “question about stuff” ticket should come back with low confidence and get flagged, and the outage ticket should be critical and escalated or sent to review. If everything is flagged, your confidence floor is too high or your prompt is too vague. If nothing is flagged, try a few nastier tickets.

Troubleshooting the usual suspects

Four failures account for most of the questions this kind of build produces.

  • AccessDeniedException on the first call. Either your role lacks permission to invoke the model, or the model is not enabled for your account. Check the IAM policy first, then the Bedrock console model catalog. If you use a cross-region inference profile, remember that the permission has to cover the profile and the destination Regions it can route to.
  • ValidationException mentioning the model ID. You probably used the bare model ID instead of an inference profile. On the standard runtime endpoint, Claude Haiku 5.5 has no in-region listing for commercial Regions, so use a profile with a global, us, eu, au or jp prefix.
  • KeyError or empty strings from the parser. Something read a fixed index instead of selecting text blocks. Print the full response once and look at the list of blocks. If a reasoning block leads, your helper from step four already handles it. A stray copy of older code often does not.
  • Lots of flagged tickets. Print the raw model text for a few of them. Usually the model returned valid JSON with a label outside your allowed set, such as “question”. Either add that label to the prompt or tighten the instructions, then rerun. Tuning the prompt is cheaper than tuning the threshold.

Throttling deserves its own note. If you see ThrottlingException bubbling up despite adaptive retries, lower the worker count and check your account quotas in the Service Quotas console. Quotas depend on your account and Region, and the model card does not list fixed numbers, so look at yours instead of assuming. Throttling at eight workers usually means a new account with default limits, and the fix is a quota increase request, not more retries.

Make it production-ready

You now have a working subagent. Before you aim it at real traffic, work through this short list.

  • Measure accuracy on a labeled set. Run 200 tickets you have already labeled by hand and compare. The confidence number is the model’s opinion, not ground truth.
  • Tune the effort setting. Claude Haiku 5.5 supports effort levels from low up to max, with medium as the default. A routing task rarely needs deep deliberation, so test lower levels and compare accuracy against output tokens. Look up the exact request field in the Anthropic Claude Messages API parameters instead of guessing it, and note that the model card says effort is capped at high when thinking is disabled.
  • Watch the 100K-token line. Prompts over 100,000 tokens use higher rates, so keep ticket batches small and never concatenate a whole inbox into one call.
  • Use the monitoring tools. The Bedrock monitoring documentation covers CloudWatch metrics and invocation logging, and usage and spend can be tracked in AWS Cost Explorer. Usage is billed through AWS Marketplace and appears under the model provider on your bill, so look there.
  • Use an application inference profile per team or per agent so costs separate cleanly.
  • Move the worker count into configuration and add a dead-letter file for tickets that fail after retries.

AWS also published a getting-started notebook for the model, which is worth a skim for the other two ways in, the InvokeModel API and the Anthropic SDK pointed at the Bedrock endpoint: the Haiku 5.5 getting-started notebook.

What you built

You built a Claude Haiku 5.5 subagent that classifies support tickets with a strict JSON contract, a parser that never trusts the model blindly, a parallel runner, a cached system prompt, and an escalation path that either calls a bigger model or hands off to a human. That is the whole shape of a subagent fleet: narrow job, fast model, validation, escalation. Planning agents that dispatch dozens of workers like this one are simply this program with a bigger loop around it.

Cleanup

This tutorial creates nothing persistent in AWS. There are no endpoints, no stacks and no storage to delete. Bedrock charges only for the tokens you used. To tidy up locally, deactivate and delete the project.

deactivate
cd .. && rm -rf ticket-router
unset TRIAGE_MODEL_ID ESCALATION_MODEL_ID AWS_REGION

If you created an IAM role or Bedrock API key only for this exercise, remove or rotate it in IAM now. Leaving credentials lying around is the one genuinely expensive mistake in this whole guide.

ENKOMPASS IS HERE TO HELP

At Enkompass (enkompass.net), this is our home turf. We build and operate Bedrock subagent fleets for clients, including prompt design, caching, guardrails, evaluation sets and escalation paths that keep humans in the loop. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.

Leave a Reply

Ready when you are

Let’s map your route to AWS

A free 60-minute architecture review with a senior engineer. No slides, no SDR, just a plan.