AI Customer Support Agent with Tool Calling
A tool-calling support agent that resolves account, billing, and product questions end-to-end, escalating cleanly when confidence drops.

Highlights
- LangGraph state machine coordinating retrieval, tool use, and escalation.
- Function-calling adapters for CRM, billing, and order-management APIs.
- Full trace logging and evaluation harness for regression tests on real conversations.
Outcomes
- Automated resolution of 45% of tier-1 support tickets with human-in-the-loop review.
- Cut average handling time on escalated tickets by 30%.
Stack
- OpenAI API
- LangGraph
- FastAPI
- Redis
- PostgreSQL
Case study · 18 min read
The full write-up
TL;DRI built a tool-calling LLM agent that resolves nearly half of tier-1 support tickets end-to-end and hands off the rest cleanly to humans. A LangGraph state machine, tightly-scoped tools, a confidence-based escalation policy, and a replayable evaluation harness were the four pieces that turned it from a fragile demo into something a real support org depends on.
The problem I was solving
Every support org I have worked with is drowning in the same shape of ticket. Password resets, invoice reissues, subscription upgrades, refund status checks, plan-change questions, and 'my thing is not working, please help' first-contact messages make up the majority of inbound volume, and they all follow the same rough pattern: identify the customer, look up state in one or two internal systems, either answer directly or take a small action, and either resolve or escalate. Human agents do this work well but at high cost per ticket, and the work is repetitive enough that the good agents burn out and leave.
The ask was to build an agent that could handle the tier-1 slice of this volume, keep a human in the loop for anything ambiguous, and never take an irreversible action without either high confidence or explicit human approval. The last part is where most support-bot projects fail. It is not hard to build something that looks smart in a demo. It is hard to build something that will not confidently issue a refund to the wrong customer, or cancel a subscription based on a misparsed intent, at 2 a.m. on a Saturday when no human is watching.
The pilot scope was three product surfaces: account and login, billing and subscriptions, and order status for a physical goods line. Each had roughly a dozen well-defined operations and a much larger set of read-only lookups. I had access to the existing knowledge base, the CRM, the billing system, the order-management system, and the ticket system. The success criteria were a resolution rate above 40% on tier-1 volume, an escalation quality high enough that agents preferred receiving an escalated ticket over a raw one, and zero incidents involving an unauthorized irreversible action.
Constraints and non-goals
The constraints I wrote down were the ones that would keep the system safe rather than the ones that would make it clever. Every tool call had to be idempotent or gated by an approval step. Every action that touched money, entitlements, or personal data had to have a full audit trail that a human could replay after the fact. The agent was never allowed to make more than a small, fixed number of tool calls before either producing an answer or handing off to a human. And the agent was not allowed to speculate about internal system state; if a lookup failed, the correct behavior was to say so, not to guess.
The non-goals were equally important. I did not try to build a general-purpose assistant. The agent's persona and scope were tightly bounded to the three product surfaces, and out-of-scope questions were routed cleanly rather than answered creatively. I did not try to give the agent long-term memory across sessions in the first version, because cross-session memory is a whole additional class of failure modes and I wanted the base system to be rock solid first. And I did not try to let the agent draft outbound communications for anything higher-stakes than the immediate reply. Marketing emails, dunning notices, and legal communications were all explicitly out of scope.
I also set a latency target of 4 seconds to first token and 12 seconds to a resolved reply on the median ticket. Slower than that and the agent feels sluggish; faster than that and I would have been cutting corners on retrieval and validation that I was not willing to cut.
The architecture I chose and why
The system is a LangGraph state machine wrapped in a FastAPI service. When a ticket arrives, it is classified into an intent bucket by a small classifier that runs in about 40 ms, and the classification determines which subgraph of the state machine it enters. Each subgraph is a small, opinionated flow: identify the customer, gather the minimum required state, decide whether the answer is a lookup, a knowledge-base answer, or an action, execute, and either resolve or escalate. Every transition is a node, every node's inputs and outputs are typed, and every tool call is a node with strict schema validation on both sides.
I chose an explicit state machine over a purely LLM-driven ReAct loop because the failure modes of a free-form agent are hard to reason about and harder to test. A state machine is boring, but boring is exactly what a support agent should be. The LLM is used at three points in a typical flow: to interpret the customer's message, to pick from a bounded set of tools, and to write the final natural-language reply. Everything else is deterministic code. When something goes wrong, I can point at a specific node and reproduce the bug. When a stakeholder asks 'why did it do that,' I can show them the graph and the trace.
Retrieval into the knowledge base is a small RAG pipeline that runs behind one of the tools. It is intentionally simpler than the enterprise RAG system I built elsewhere, because the corpus is smaller and the questions are more predictable. Hybrid retrieval, a light reranker, and a strict citation-or-refuse prompt. The agent never quotes the knowledge base without a citation, and the reply UI links customers directly to the source article for anything non-trivial.
Tool calls happen through a thin adapter layer that wraps each internal system's API with a narrower interface. The CRM has hundreds of endpoints; the agent sees six of them, each with a locked-down schema and a per-tool rate limit. The billing system has similar breadth; the agent sees five actions and a small set of read-only lookups. This narrow surface is the single most important safety feature in the system. The agent cannot do things it does not have a tool for, and the tools it has are audited.
Key technical decisions and tradeoffs
The first big decision was the escalation policy. I evaluated three options: escalate on model uncertainty, escalate on rule-based triggers, and a hybrid. Pure model uncertainty is unreliable because modern LLMs are frequently confidently wrong; the token-level probabilities are not calibrated to the underlying task. Pure rule-based triggers over-escalate and defeat the point. The hybrid that shipped uses rule-based triggers for known high-risk actions such as any refund above a threshold, any change to a legacy plan, or any account with an open dispute, and a learned confidence signal for the softer cases. The learned signal is a small classifier trained on labeled outcomes from the eval set and from historical escalations. It is not the LLM's self-reported confidence, which I do not trust.
The second decision was how much to let the agent write versus how much to templatize. My first instinct was to let the LLM write every reply. That produced replies that were fluent but sometimes off-tone and occasionally invented policy details. I settled on a middle path: the LLM writes the empathetic framing and the answer body, but any statement about a policy, a price, a date, or an account state is rendered from a template with data pulled from tool calls. The LLM is told, in the prompt, that it must use template variables for those facts and that it cannot invent numbers. This is enforced by a validator that scans the reply for numeric or date-shaped tokens that are not tied to a template variable and rejects the reply if it finds them.
The third decision was the memory model. Within a single ticket, the agent has full context of the conversation and its own prior tool calls. Across tickets, it has no memory. Cross-ticket memory would improve personalization but would also introduce a whole class of privacy and correctness bugs that I was not ready to underwrite in the first version. In version two we added a very restricted form of cross-ticket memory limited to preferences the customer had explicitly set, and even that took two months of hardening.
The fourth decision was human-in-the-loop UX. The escalated ticket does not arrive to a human agent as a raw customer message. It arrives with a structured summary of what the agent tried, which tools it called, what it found, and where it got stuck, plus a suggested next action. Agents can accept the suggested action, edit it, or ignore it entirely. The suggestion is optional, but it means the agent picking up the ticket is starting from a warm context instead of a cold one. Agent satisfaction on escalated tickets is where I saw the largest qualitative win from the system.
The fifth decision was rollout. I refused to ship to 100% of tier-1 volume on day one. The rollout was shadow mode for two weeks, where the agent processed every ticket in parallel with the human agent and its output was logged but never sent, followed by a canary at 5% of volume for two weeks, then a ramp to 25%, 50%, and finally full tier-1 volume over roughly two months. At each stage, the evaluation harness ran against the previous week's traffic and any regression on any metric blocked the next ramp. This felt slow at the time. It is the only reason I can talk about zero unauthorized irreversible actions with a straight face.
Implementation deep-dive
The LangGraph state machine has a small number of top-level flows: identify, answer, act, and escalate. Identify handles customer verification, which is stricter than most demos take it. The agent does not trust that the message came from the account it says it came from until it has verified through the identity provider. Answer is the read-only path: retrieve context, generate a reply, cite. Act is the writable path: pick a tool, validate arguments, execute, verify the result, reply. Escalate is the handoff path: package context, choose queue, hand off.
The tool schemas are strict. Every tool has a Pydantic input model with tight types and constraints, a docstring that is used verbatim in the LLM's tool description, and a small set of documented failure modes. If the LLM tries to call a tool with arguments that fail validation, the tool call is rejected, the failure is surfaced back to the LLM as an error message, and the agent is allowed one retry with corrected arguments before the flow escalates. I refuse to loop on tool-argument errors indefinitely; that is one of the classic ways agents get stuck.
The retry-and-escalate logic is deliberately shallow. In the state machine, the maximum number of tool calls per ticket is bounded at eight, and a ticket that hits the bound is escalated automatically. I could have made this higher and squeezed a few more percentage points of resolution, but I could not have kept the p95 latency in budget, and I could not have kept the trace log short enough for humans to review efficiently. Eight is a hard number, and it is short by design.
The reply validator I mentioned earlier is worth describing in detail because it is doing more work than it looks like. It parses the generated reply, extracts every span that looks like a number, a currency amount, a date, an account identifier, or a policy claim, and checks each one against either a template variable or a citation in the retrieved context. Spans that do not match are flagged, and the reply is rejected. This validator catches roughly 3% of generated replies. Of those, most are salvaged by a single regeneration with a stricter prompt; the remainder are escalated. Without this layer, the number of embarrassing 'the agent quoted the wrong price' incidents would be many multiples of what it is.
Observability is central. Every ticket has a trace with the classification, the state machine path, every tool call with arguments and results, every LLM call with prompts and responses, the validator's decisions, and the final reply. Traces feed a searchable log store that support leads can query. When a customer complains about a bad reply, we can pull the trace in under a minute and understand exactly what happened. That capability is what turned a skeptical support director into an ally.
What broke in production and how I fixed it
The first sharp incident was a prompt-injection attempt embedded in a customer's ticket. The customer had pasted content from an internal document into their message, and the content contained instructions that looked like a system prompt. The agent partially complied and briefly produced a reply that leaked structure it should not have leaked. The reply never sent because the validator caught it, but it should not have been generated at all. The fix was two-layered. First, I added an input sanitizer that strips content that pattern-matches instruction-shaped text from the customer's message before it hits the LLM. Second, I hardened the system prompt to explicitly instruct the model to treat the customer message as untrusted data. Neither layer alone is sufficient. Both together closed the specific class of attack, but I do not claim I have solved prompt injection in general, and I do not believe anyone has.
The second incident was a tool-schema drift. The billing system's API changed the shape of one field in a minor version bump, and our adapter's Pydantic model was slightly out of date. The tool call started failing intermittently. The state machine correctly escalated the failing tickets, so no customer got a wrong answer, but the escalation rate on billing tickets spiked overnight and my dashboards lit up. The fix was a contract test in CI that hits a staging instance of each downstream system daily and verifies that every tool's input and output schema still matches. That test would have caught the drift before it hit production. It is now a required gate.
The third incident was a slow-cooking one: model drift after a provider silently updated the underlying model behind the same version string. I do not have visibility into their release cadence, but the eval numbers moved. Resolution rate dropped by about 4 points across a two-week window with no code changes. I now pin to specific model versions where the provider offers that option, and where they do not, I have a canary comparison in the nightly eval that compares the current model output to a golden set and alerts on divergence. This is a persistent operational cost of building on hosted models, and I plan for it explicitly.
The fourth ongoing lesson is about the intent classifier. Intents drift as products change. A new feature launches, customers ask about it, and the classifier routes them into a bucket that does not have a good playbook. I now retrain the classifier monthly on the past thirty days of labeled tickets, and I have a lightweight review step for any intent whose share of volume moves more than a few points week over week. Intent drift, not model quality, is the more common source of quiet regressions in this system.
Results and measurable outcomes
By the end of the pilot, the agent was handling roughly 45% of tier-1 ticket volume end-to-end with a first-contact resolution rate of 78% on the tickets it took. Average handling time on escalated tickets dropped 30% versus the baseline, driven almost entirely by the warm-context handoff. Customer satisfaction on agent-handled tickets, measured through the same post-ticket survey as human-handled tickets, was within a percentage point of human performance and slightly higher on the fastest response category, which was not surprising given the latency profile.
Zero unauthorized irreversible actions were logged in the pilot. There were three near-misses, all caught by either the validator or the human-approval gate. Each near-miss was written up as an incident and produced a change to either the tool schema, the prompt, or the validator. Near-misses are cheaper than incidents and I welcomed them.
Cost per handled ticket was about a fifth of the fully-loaded cost of the equivalent human interaction. That is the number that pays for the project, but it is not the number I lead with when I present the system. The number I lead with is that the human agents freed up by the agent are now handling the tier-two and tier-three work that used to sit in queues for hours. Redirected human capacity is the real story.
What I would do differently
I would build the shadow-mode logging before I built the state machine. It is the fastest way to accumulate real, labeled traffic to test against, and I ended up wishing I had two more weeks of shadow data at almost every rollout gate.
I would push harder on the tool-adapter layer as a first-class product. It is the piece of the system that keeps the agent safe, and it is the piece that gets the least glamour. Every hour I spent making tool descriptions clearer, error messages more actionable, and schemas tighter paid back many times over in agent behavior. I would treat the tool layer with the seriousness I treat a public API.
And I would have written the runbook for 'what happens when the agent is wrong in front of a customer' on day one. We wrote it in week five after the first embarrassment. The runbook itself is not long, but writing it forces you to think about ownership, communication, and remediation before you need them, and I would rather have that clarity going in than acquire it under fire.