Skip to main content
AI Customer Support Architecture
View as Markdown Suggest changes

Architecture of a First-Level Support Automation

· Reading time: 7 min
Architecture of a First-Level Support Automation

How do you automate first-level customer support without building one big, opaque agent? This post presents the architecture we reached in production: decompose the problem, let LLMs handle interpretation, and keep everything else in code. It is a worked example of Agentic vs Workflow-based AI.

The system turns a customer message into a reply, possibly with attachments, and any required tool calls: invalidating a voucher, filing a reimbursement ticket, or escalating to a human. A preprocessing layer first normalises inbound attachments.

In the diagrams, 🤖 Agent is autonomous, 🧠 LLM-assisted step is a constrained model call, ⚙️ Code step is deterministic logic, and 🔌 External tool/API is an external dependency.

The pipeline has four steps, run as a DAG:

flowchart TD
    INPUT(["Customer message<br/>+ attachments"])
    L1["🧠 LLM-assisted step<br/>intent recognition"]
    L2["🧠 LLM-assisted step<br/>information extraction"]
    C1["⚙️ Code step<br/>fetch and score records"]
    C2["⚙️ Code step<br/>dispatch and plan generation"]
    A1["🤖 Agent<br/>fallback for an 'other' intent"]
    OUT(["Reply + tool calls"])

    INPUT -->|"message"| L1
    INPUT -->|"message"| L2
    L2 -->|"structured fields"| C1
    L1 -->|"resolved intent leaf"| C2
    C1 -->|"candidate records"| C2
    C2 -->|"known leaf"| OUT
    C2 -.->|"'other' leaf"| A1
    A1 -->|"proposed plan"| OUT

    classDef agent fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
    classDef llm fill:#dbeafe,stroke:#2563eb,stroke-width:2px
    classDef code fill:#dcfce7,stroke:#16a34a,stroke-width:2px
    classDef terminal fill:#f8fafc,stroke:#64748b,stroke-width:1.5px
    class A1 agent
    class L1,L2 llm
    class C1,C2 code
    class INPUT,OUT terminal

Intent recognition and extraction run in parallel. Fetch uses the extracted fields; plan generation combines its results with the resolved intent. Known intents follow code, while an “other” intent uses the fallback agent.

We will trace this message through all four steps:

I bank with Northstar Bank’s Riverton branch. My name is Alex Carter, and my partner is Jamie Carter. Our account number is NSBO1234. We can no longer access the account.

All details are fictional. The example contains three common complications: an ambiguous intent, two named people, and a 0/O ambiguity in the account number (NSBO1234 or NSB01234).


Step 1: Intent recognition

We classify the message against a rooted DAG of intents, biased toward depth 2. The root contains broad intents (“account access”, “payments”, “card services”, …) and an explicit “other”; most parents contain a few sub-intents and another “other”. One constrained LLM call selects the parent, then another selects among its children. These Pydantic AI Agent calls have no tools or open-ended loop and return typed outputs. Type-Safe Hybrid Workflows with Pydantic AI covers the mechanics.

flowchart TD
    R(["Customer message"])
    R --> P1["Account access"]
    R --> P2["Payments"]
    R --> P3["Card services"]
    R --> POT["Other"]

    P1 --> P1A["Login problem"]
    P1 --> P1B["Password reset"]
    P1 --> LOCKED["Account locked"]
    P1 --> P1OT["Other"]

    P3 --> P3A["Card blocked"]
    P3 --> LOCKED
    P3 --> P3OT["Other"]

    classDef intent fill:#f8fafc,stroke:#64748b,stroke-width:1.5px
    classDef selected fill:#dbeafe,stroke:#2563eb,stroke-width:2px
    classDef fallback fill:#fff7ed,stroke:#ea580c,stroke-width:2px,stroke-dasharray:4 4
    class R,P1,P2,P3,P1A,P1B,P3A intent
    class LOCKED selected
    class POT,P1OT,P3OT fallback

Depth 2 is a bias, not a rule. Specific parents can resolve at depth 1; rare branches may need a third level. Two is usually deep enough to keep each classification narrow without over-classifying or making labels harder to maintain.

Shared leaves make this a DAG, not a tree. Account locked belongs under both Account access and Card services. Once either path reaches that leaf, handling is identical. A tree would duplicate the unlock flow; a DAG keeps one plan and makes downstream logic depend on the leaf, not the route.

“Other” makes coverage gaps explicit. Requests outside the taxonomy land in a visible queue for future intents and route cleanly to less structured handling.

Two narrow calls beat one wide classifier. Each parent exposes only its reachable children as a typed Literal, avoiding confusion between unrelated categories. Shared leaves simply appear in more than one parent’s options.

Intent recognition is also the pipeline’s cleanest eval target: labeled conversations yield precision, recall, and a confusion matrix.

Our example resolves to account access → account locked, the same leaf a card-PIN lockout could reach through card services.


Step 2: Information extraction

In parallel with intent recognition, separate LLM-assisted steps extract groups of related fields. Our example uses People (names) and Account (identifiers and branch). Each group has its own prompt and Pydantic output model, including validation and retries. The Pydantic AI post shows the wiring.

flowchart TD
    MSG(["Customer message"])

    subgraph people["People group"]
        P_STEP["🧠 LLM-assisted step<br/>People prompt<br/>output: PeopleOutput"]
        P_FIELDS["Given name · Family name"]
        P_STEP -->|"typed fields"| P_FIELDS
    end

    subgraph account["Account group"]
        A_STEP["🧠 LLM-assisted step<br/>Account prompt<br/>output: AccountOutput"]
        A_FIELDS["Account number · Branch"]
        A_STEP -->|"typed fields"| A_FIELDS
    end

    MSG -->|"message"| P_STEP
    MSG -->|"message"| A_STEP
    P_FIELDS --> OUT(["Extracted information"])
    A_FIELDS --> OUT

    classDef llm fill:#dbeafe,stroke:#2563eb,stroke-width:2px
    classDef data fill:#f8fafc,stroke:#64748b,stroke-width:1.5px
    class P_STEP,A_STEP llm
    class MSG,P_FIELDS,A_FIELDS,OUT data

Groups preserve field relationships. A single prompt can partition “Northstar Bank’s Riverton branch” consistently instead of extracting the bank and branch independently and duplicating or losing information.

Groups are independent eval targets. A regression is isolated to one dataset and component, which can be tuned, moved to another model, or replaced with NER without affecting the rest.

Across groups, we use the same patterns:

PatternWhy
All fields optionalCustomers rarely include everything; we extract what is there
List-valued where ambiguousIf two emails are mentioned, both get returned; downstream disambiguates
Automatic alphanumeric variantsNSBO1234 is stored as [NSBO1234, NSB01234] to handle 0/O, 1/I, 5/S confusions
Bias toward false positivesEasier to filter spurious matches downstream than to recover lost signal

We deliberately over-extract: fetch treats every field as fallible and scores the combined evidence.

The example yields:

  • People: Given name: [Alex, Jamie], Family name: [Carter]
  • Account: Account number: [NSBO1234, NSB01234], Branch: [Riverton]

Step 3: Fetch

Fetch uses a weighted-scoring fan-out to absorb extraction’s false positives. Each field triggers a parallel API query; candidate lists are merged by primary ID, adding a field-specific weight for every hit. Strong records accumulate evidence while spurious fields contribute little.

flowchart TD
    EX(["Extracted information"])
    Q1["🔌 External tool/API<br/>given_name=Alex<br/>given_name=Jamie"]
    Q2["🔌 External tool/API<br/>family_name=Carter<br/>branch=Riverton"]
    Q3["🔌 External tool/API<br/>account_number=NSBO1234<br/>account_number=NSB01234"]
    C1["⚙️ Code step<br/>merge by primary ID<br/>and sum signal strengths"]
    C2["⚙️ Code step<br/>keep the maximum score<br/>above the threshold"]
    OUT(["Candidate records"])

    EX -->|"parallel queries"| Q1
    EX -->|"parallel queries"| Q2
    EX -->|"parallel queries"| Q3
    Q1 -->|"candidate hits"| C1
    Q2 -->|"candidate hits"| C1
    Q3 -->|"candidate hits"| C1
    C1 -->|"scored records"| C2
    C2 --> OUT

    classDef code fill:#dcfce7,stroke:#16a34a,stroke-width:2px
    classDef tool fill:#fef3c7,stroke:#d97706,stroke-width:2px
    classDef terminal fill:#f8fafc,stroke:#64748b,stroke-width:1.5px
    class C1,C2 code
    class Q1,Q2,Q3 tool
    class EX,OUT terminal

Weights reflect how discriminating each field is: a first name matches many customers, while an account number matches one or none.

FieldWeight
Given name1
Family name2
Branch2
Account number5

The example produces these simplified hits:

Record IDMatched onScore
acc_001Given name=Alex, Family name=Carter, Branch=Riverton, Account number=NSB012341 + 2 + 2 + 5 = 10
acc_017Given name=Jamie, Family name=Carter1 + 2 = 3
acc_204Family name=Carter2
acc_388Given name=Alex1

acc_001 is the sole maximum above the threshold, so it wins. Its account-number hit comes from the generated 0/O variant: the customer wrote NSBO1234, while the record contains NSB01234.

If nothing clears the threshold, fetch returns no record and the plan can request more information. This step is plain code: fan out, sum weighted hits, threshold.


Step 4: Plan generation

Plan generation receives the resolved intent leaf, extracted fields, fetched records, and fetch metadata. Dispatch is therefore a keyed lookup, not another classification: converging paths share one plan, so account_locked behaves the same whether reached through account access or card services.

Each known leaf owns a small decision tree over the fetched data: was a record found, is there one candidate, is it flagged? Branches end in a pre-written template plus any tool calls. These trees encode the business logic and grow as patterns emerge. Agentic vs Workflow-based AI explains why we keep them in code.

flowchart TD
    ROOT(["Resolved intent leaf<br/>+ fetched data"])
    C1["⚙️ Code step<br/>dispatch on resolved leaf"]
    A1["🤖 Agent<br/>propose fallback plan"]
    D1{"Account<br/>found?"}
    D2{"Exactly one<br/>candidate?"}
    D3{"Fraud flag<br/>set?"}
    C2["⚙️ Code step<br/>request account information"]
    C3["⚙️ Code step<br/>request account disambiguation"]
    C4["⚙️ Code step<br/>send unlock instructions"]
    C5["⚙️ Code step<br/>escalate to security"]

    ROOT --> C1
    C1 -.->|"'other' leaf"| A1
    C1 -->|"account_locked"| D1
    D1 -->|"no"| C2
    D1 -->|"yes"| D2
    D2 -->|"no"| C3
    D2 -->|"yes"| D3
    D3 -->|"no"| C4
    D3 -->|"yes"| C5

    classDef agent fill:#f3e8ff,stroke:#7c3aed,stroke-width:2px
    classDef code fill:#dcfce7,stroke:#16a34a,stroke-width:2px
    classDef decision fill:#fff7ed,stroke:#ea580c,stroke-width:2px
    classDef terminal fill:#f8fafc,stroke:#64748b,stroke-width:1.5px
    class A1 agent
    class C1,C2,C3,C4,C5 code
    class D1,D2,D3 decision
    class ROOT terminal

“Other” leaves are the exception. With no hardcoded plan, the pipeline’s only autonomous agent inspects the fetched data and proposes one using constrained tools. Repeated patterns are promoted to hardcoded leaves through an observe, label, generalise, hardcode loop, reducing LLM dependence over time.

Our account_locked example finds one unflagged record, acc_001, and lands on the parametrised “unlock instructions” template.

Plans involving money, account access, or side effects require human review. Temporal for Human-in-the-Loop covers durable waits, signals, and recovery across restarts.


Why decomposing pays off: evals

Each step has its own evaluation:

  • Intent: precision, recall, and a confusion matrix over labeled parent/sub-intents.
  • Extraction: field-level precision and recall for each group, allowing the intended false-positive bias.
  • Fetch: integration tests over extracted fields, fixed API responses, weights, and thresholds.
  • Plan: unit tests for known leaves; a labeled golden set for fallback plans.

A monolithic agent offers only end-to-end quality, making regressions hard to locate. Decomposition isolates them. Meanwhile, “other” buckets form a self-curating queue for new intents, extraction groups, or hardcoded branches.


Closing

Decompose into typed steps, reserve LLMs for interpretation, encode known execution in code, and make coverage gaps explicit with “other”. The result is an evolvable system where regressions are easy to locate and the long tail is manageable. The same pattern extends naturally to operations triage, legal intake, and insurance claims.

AI Chat

Messages you send are processed by the Google Gemini API to generate responses. Do not share sensitive personal data. See the privacy policy for details.