Introduction
Customer support is chaotic, and thinking about ways to automate it can be tricky. You never know what a customer will ask for, or when. To handle this, people usually rely on patterned automated systems that work based on a few conditions. If a request fits those conditions, it works fine. If not, it gets passed to a human.
That’s the problem. Customers don’t follow patterns. A simple question might need escalation, and an urgent issue might have a quick fix.
In this tutorial, we’ll build a multi-agent support system using CopilotKit and LangGraph. In this system, multiple agents will coordinate resolutions based on the customers' questions or issues.
Before we start building, let’s first understand what CopilotKit is and why it fits this problem.
What is CopilotKit?
CopilotKit is an open-source framework for building AI copilots that integrate directly into your applications. It provides UI components and infrastructure to connect backend agents with frontend UIs, enabling real-time context sharing and state synchronization.
Check out CopilotKit's GitHub ⭐️
In this project, it will also use AG-UI (Agentic UI) under the hood, an event-based protocol that lets agents do more than just respond in chat. Agents can trigger actions, update UI state, and interact directly with the application. For example, an agent can modify a service plan, update a customer record, or adjust billing settings through frontend tools.
CopilotKit works with multiple agent frameworks, including LangGraph, and uses AG-UI as a common event layer. If you’re new to CopilotKit, you can explore the documentation to get a better overview before continuing.
What We're Building
We’ll build a telecom support application with a chat interface that lets customers manage their services, update plans, check billing, and get instant help.
Here's what happens when a customer asks something like, "Add international calling to my plan":
The system features four specialized agents:
- Intent Agent: Classifies customer messages and determines urgency level
- Customer Lookup Agent: Retrieves customer profiles and service details from the database
- Reply Agent: Generates personalized responses and processes service changes
- Escalation Agent: Routes complex issues to appropriate human support teams with full context
The Tech Stack
At the core, we're using this stack for building the multi-agent support system:
- Next.js: Frontend framework with TypeScript
-
CopilotKit SDK: Embed agents into UI (
@copilotkit/react-core,@copilotkit/runtime,@copilotkit/react-ui) - LangGraph (StateGraph): Stateful agent workflows with coordination
- OpenAI: LLM for reasoning and response generation
- LangChain's OpenAI adapter: Connects OpenAI to LangChain workflows
- Turborepo: Monorepo management for web + agent apps
- pnpm workspaces: Package management across the monorepo
Architecture Overview
Here's the high-level architecture showing how the frontend, CopilotKit runtime, and LangGraph agents work together:
The Next.js frontend communicates with LangGraph agents through the CopilotKit Runtime API route. When a customer sends a message, it flows through the runtime to the multi-agent system, where specialized agents coordinate to handle the request. State updates stream back to the UI in real-time, so customers see changes as they happen.
Project Structure
This project is organized as a monorepo. It consists of two main applications: the Next.js frontend and the LangGraph multi-agent backend, along with shared configuration files.
support-help/
├── apps/
│ ├── web/ <- Next.js frontend application
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── page.tsx <- Main dashboard UI
│ │ │ │ ├── layout.tsx <- Root layout with CopilotKit provider
│ │ │ │ ├── globals.css <- Global styles
│ │ │ │ └── api/
│ │ │ │ └── copilotkit/ <- CopilotKit runtime endpoint
│ │ │ │ └── route.ts <- API route handler
│ │ │ ├── components/
│ │ │ │ └── WelcomeScreen.tsx
│ │ │ ├── hooks/
│ │ │ │ └── CustomerContext.tsx <- Customer state & frontend tools
│ │ │ ├── data/
│ │ │ │ └── ticketsData.ts <- Sample ticket data
│ │ │ └── utils/
│ │ │ └── servicePricing.ts <- Service pricing calculations
│ │ ├── package.json
│ │ └── next.config.ts
│ │
│ └── agent/ <- LangGraph multi-agent backend
│ ├── src/
│ │ ├── agent.ts <- Main agent workflow & graph definition
│ │ ├── index.ts <- Entry point
│ │ ├── agents/ <- Agents
│ │ │ ├── intentAgent.ts
│ │ │ ├── ...
│ │ ├── tools/ <- Tools
│ │ │ ├── intentClassifier.ts
│ │ │ ├── ...
│ │ ├── types/
│ │ │ └── state.ts
│ │ └── utils/
│ │ └── dataLoader.ts
│ ├── package.json
│ └── langgraph.json # LangGraph configuration
│
Here's the GitHub repository if you want to see the project and explore the implementation in more detail.
Add API Key
Create a .env file under the agent directory and add your OpenAI API Key to the file.
OPENAI_API_KEY=<<your-openai-key-here>>
Let's build the frontend first.
Frontend
The frontend is in apps/web and handles the customer dashboard, chat interface, and real-time state management. We’ll connect it to the LangGraph agents using CopilotKit.
If you're starting fresh, create a new Next.js app with TypeScript:
npx create-next-app@latest web
In our cloned repository, everything's already set up, so just install dependencies:
cd apps/web
pnpm install
Step 1: Install CopilotKit Packages
Install the necessary CopilotKit packages:
pnpm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime
Here's what each package does:
- @copilotkit/react-core - Core hooks and context to connect your React app with the agent backend
-
@copilotkit/react-ui - Ready-made UI components like
<CopilotSidebar />for chat interfaces - @copilotkit/runtime - Server-side runtime that connects your Next.js API route to LangGraph agents
Step 2: Set Up the Root Layout
The <CopilotKit> provider needs to wrap your app. Open src/app/layout.tsx:
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
import { CopilotKit } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";
// ...
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={geist.className}>
<CopilotKit runtimeUrl="/api/copilotkit">
{children}
</CopilotKit>
</body>
</html>
);
}
The runtimeUrl specifies where CopilotKit can find your agent endpoint. It is also the way to securely communicate on the backend. We'll create that next.
Step 3: Create the CopilotKit Runtime Endpoint
Create src/app/api/copilotkit/route.ts. This is the bridge between your frontend and the LangGraph agent:
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { NextRequest } from "next/server";
// 1. You can use any service adapter here for multi-agent support.
const serviceAdapter = new ExperimentalEmptyAdapter();
// 2. Create the CopilotRuntime instance and utilize the LangGraph AG-UI
// integration to set up the connection.
const runtime = new CopilotRuntime({
agents: {
starterAgent: new LangGraphAgent({
deploymentUrl: process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123",
graphId: "starterAgent", // <- Graph id will be the agent name that we'll use next in our context file when we'll define the Agent using useAgent hook
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
})
}
});
// 3. Build a Next.js API route to handle CopilotKit runtime requests.
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: "/api/copilotkit",
});
return handleRequest(req);
};
This API route proxies requests between the frontend and your LangGraph agent, handling authentication and keeping API keys secure on the server.
CopilotRuntime manages agent communication, OpenAIAdapter connects to GPT model, and copilotRuntimeNextJSAppRouterEndpoint processes and routes incoming requests.
Step 4: Create Customer Context with Frontend Tools
Here's the thing about building with CopilotKit: if you want agents to actually modify your UI in real-time, your state management needs to be solid. Scattering state across multiple files or skipping the global state entirely makes it nearly impossible for agents to properly update the UI.
In this system, we're using React Context to centralize customer data and expose it to agents through useAgent and useFrontendTool. This pattern ensures that agents and UI components use the same source of truth and supports bidirectional state synchronization.
Create src/hooks/CustomerContext.tsx. This is where we centralize customer state and expose tools that agents can call to modify the UI.
First, set up the basic structure:
"use client";
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from "react";
import { useAgent, useFrontendTool } from "@copilotkit/react-core/v2";
import { initialCustomers } from "@/data/ticketsData";
interface Customer {
id: number;
customerID: string;
gender: string;
SeniorCitizen: string;
Partner: "Yes" | "No";
MonthlyCharges: string;
InternetService: "DSL" | "Fiber optic";
PaperlessBilling: "Yes" | "No";
StreamingTV: "Yes" | "No";
// ... other service fields
}
type AgentState = {
customers: Customer[];
};
const CustomerContext = createContext<CustomerContextType | undefined>(undefined);
export function CustomerProvider({ children }: { children: React.ReactNode }) {
// Use useAgent for bidirectional state synchronization with the agent
const { agent } = useAgent({ agentId: "starterAgent" }) // <- Agent name same as graphId
// Initialize agent state if not already set
useEffect(() => {
if (!agent.state.customers) {
agent.setState({
...agent.state,
customers: initialCustomers,
});
}
}, [agent]);
// Optimistic local state with agent sync
const [localCustomers, setLocalCustomers] = useState<Customer[] | null>(null);
// Derive customers: local override takes precedence over agent state
const customers = localCustomers ?? agent.state.customers ?? [];
// Keep a ref to always have the latest customers in closures
const customersRef = useRef<Customer[]>([]);
useEffect(() => {
customersRef.current = customers;
}, [customers]);
Next, define helper functions for state updates:
These functions handle the actual logic for modifying customer data, adding or removing services, or updating settings.
const recalculateCharges = useCallback((customer: Customer) => {
const calculation = calculateMonthlyCharges(customer);
const monthlyCharges = calculation.total;
const tenure = parseInt(customer.tenure) || 0;
const totalCharges = monthlyCharges * tenure;
return { monthlyCharges, totalCharges };
}, []);
// Helper: Add a service addon to a customer
const addAddon = useCallback((customerId: string, addon: AddonService) => {
let updatedCustomer: Customer | null = null;
const updatedCustomers = customersRef.current.map((customer) => {
if (customer.customerID === customerId) {
const updates: Partial<Customer> = {};
// Handle service dependencies (e.g., PhoneService required for MultipleLines)
if (addon === "MultipleLines" && customer.PhoneService === "No") {
return customer; // Can't add MultipleLines without PhoneService
}
updates[addon] = "Yes";
const updated = { ...customer, ...updates };
// Recalculate charges automatically
const { monthlyCharges, totalCharges } = recalculateCharges(updated);
updated.MonthlyCharges = monthlyCharges.toFixed(2);
updated.TotalCharges = totalCharges.toFixed(2);
updatedCustomer = updated;
return updated;
}
return customer;
});
if (updatedCustomer) {
// Update BOTH local and agent state
setLocalCustomers(updatedCustomers);
agent.setState({ ...agent.state, customers: updatedCustomers });
}
return updatedCustomer;
}, [recalculateCharges, agent]);
// Helper: Update customer settings
const updateCustomer = useCallback((customerId: number, updates: Partial<Customer>) => {
let updatedCustomer: Customer | null = null;
const updatedCustomers = customers.map((customer) => {
if (customer.id === customerId) {
const updated = { ...customer, ...updates };
// Handle service dependencies
if (updates.PhoneService === "No") {
updated.MultipleLines = "No phone service";
}
// Recalculate charges if any service changed
const { monthlyCharges, totalCharges } = recalculateCharges(updated);
updated.MonthlyCharges = monthlyCharges.toFixed(2);
updated.TotalCharges = totalCharges.toFixed(2);
updatedCustomer = updated;
return updated;
}
return customer;
});
setLocalCustomers(updatedCustomers);
agent.setState({ ...agent.state, customers: updatedCustomers });
return updatedCustomer;
}, [customers, recalculateCharges, agent]);
Define the first frontend tool: Add Addon
Use useFrontendTool to create a tool that agents can call. The handler executes our addAddon function when the agent needs to add a service.
// Frontend Tool: Add service addon
useFrontendTool({
name: "addAddonToCustomer",
description: "Add a service addon to a customer. This will enable a specific service for the customer and recalculate their monthly charges automatically.",
parameters: [
{
name: "customerID",
type: "string",
description: "The unique customer ID (e.g., '5575-GNVDE', '7590-VHVEG')",
required: true,
},
{
name: "addonName",
type: "string",
description: "The name of the addon service to add. Valid options: PhoneService, MultipleLines, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies",
required: true,
},
],
handler: async ({ customerID, addonName }) => {
// Access current state directly from ref to avoid stale closure
const currentCustomers = customersRef.current;
const customer = currentCustomers.find((c) => c.customerID === customerID);
if (!customer) {
return {
success: false,
message: `Customer with ID ${customerID} not found`,
};
}
const result = addAddon(customer.customerID, addonName as AddonService);
if (!result) {
return {
success: false,
message: `Failed to add ${addonName}. Check if prerequisites are met (e.g., PhoneService required for MultipleLines)`,
};
}
return {
success: true,
message: `Successfully added ${addonName} to customer ${customer.customerID}`,
newMonthlyCharges: result.MonthlyCharges,
customer: {
id: result.id,
customerID: result.customerID,
monthlyCharges: result.MonthlyCharges,
},
};
},
});
When an agent calls addAddonToCustomer, the handler finds the customer, calls our addAddon function, and returns confirmation with updated charges.
Define the second frontend tool: Remove Addon
// Frontend Tool: Remove service addon
useFrontendTool({
name: "removeAddonFromCustomer",
description: "Remove a service addon from a customer. This will disable a specific service for the customer and recalculate their monthly charges automatically.",
parameters: [
{
name: "customerID",
type: "string",
description: "The unique customer ID (e.g., '5575-GNVDE', '7590-VHVEG')",
required: true,
},
{
name: "addonName",
type: "string",
description: "The name of the addon service to remove. Valid options: PhoneService, MultipleLines, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies",
required: true,
},
],
handler: async ({ customerID, addonName }) => {
const currentCustomers = customersRef.current;
const customer = currentCustomers.find((c) => c.customerID === customerID);
if (!customer) {
return {
success: false,
message: `Customer with ID ${customerID} not found`,
};
}
const result = removeAddon(customer.customerID, addonName as AddonService);
if (!result) {
return {
success: false,
message: `Failed to remove ${addonName}`,
};
}
return {
success: true,
message: `Successfully removed ${addonName} from customer ${customer.customerID}`,
newMonthlyCharges: result.MonthlyCharges,
};
},
});
The handler removes the addon.
Wrap it up with the provider:
const value: CustomerContextType = {
customers,
addCustomer,
deleteCustomer,
updateCustomer,
addAddon,
removeAddon,
getCustomerById,
getCustomerByCustomerId,
recalculateCharges,
};
return (
<CustomerContext.Provider value={value}>
{children}
</CustomerContext.Provider>
);
}
export function useCustomers() {
const context = useContext(CustomerContext);
if (!context) throw new Error("useCustomers must be used within CustomerProvider");
return context;
}
This pattern keeps the state centralized, helper functions manage the logic, useAgent syncs data bidirectionally with agents through agent.state and agent.setState(), and useFrontendTool creates callable tools that trigger UI updates automatically. The agent can now read customer data and execute actions that are reflected in the UI immediately.
Step 5: Build the Main Dashboard UI
Create src/app/page.tsx:
"use client";
import React, { useState } from "react";
import { CustomerProvider, useCustomers } from "@/hooks/CustomerContext";
import { CopilotSidebar } from "@copilotkit/react-ui";
export default function CustomerSupportPage() {
return (
<CustomerProvider>
<div className="flex h-screen overflow-hidden">
<MainApp />
<CopilotSidebar
defaultOpen={true}
clickOutsideToClose={false}
labels={{
title: "Telecom Support Assistant",
initial: "Hi! 👋 I'm here to assist you with your telecom support needs.",
}}
suggestions={[
{
title: "🔍 Check my services",
message: "Show me services for customer ID: 5575-GNVDE",
},
// ... more you can add...
]}
/>
</div>
</CustomerProvider>
);
}
function MainApp() {
const { customers, getCustomerByCustomerId } = useCustomers();
const [selectedCustomerId, setSelectedCustomerId] = useState<string>("");
const selectedCustomer = selectedCustomerId
? getCustomerByCustomerId(selectedCustomerId)
: null;
return (
<div className="flex-1 flex flex-col bg-gray-50 overflow-hidden">
{/* Top Header */}
<header className="bg-white border-b px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Telecom Support</h1>
<select
value={selectedCustomerId}
onChange={(e) => setSelectedCustomerId(e.target.value)}
className="px-4 py-2 border rounded-lg"
>
<option value="">Select Customer Account</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.customerID}>
{customer.customerID} - {customer.gender}
</option>
))}
</select>
</div>
</header>
{/* Main Content */}
<div className="flex-1 overflow-auto p-6">
{selectedCustomer ? (
<CustomerCard customer={selectedCustomer} />
) : (
<WelcomeScreen />
)}
</div>
</div>
);
}
We're using <CopilotSidebar> for the chat interface with starter suggestions to guide users. The MainApp component displays a customer selector and shows account details when a customer is selected.
Step 6: Create the Welcome Screen
Create src/components/WelcomeScreen.tsx:
export function WelcomeScreen() {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-2xl shadow-lg p-8 border">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Multi-Agent Telecom Support System
</h2>
<p className="text-gray-600 mb-6">
This system uses 4 specialized AI agents working together to handle customer support:
</p>
<div className="grid grid-cols-2 gap-4 mb-8">
<AgentCard
icon="🎯"
title="Intent Agent"
description="Classifies customer issues and urgency"
/>
<AgentCard
icon="🔍"
title="Lookup Agent"
description="Finds customer profiles instantly"
/>
<AgentCard
icon="💬"
title="Reply Agent"
description="Generates personalized responses"
/>
<AgentCard
icon="🚨"
title="Escalation Agent"
description="Routes complex issues to humans"
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-900">
<strong>Get Started:</strong> Select a customer from the dropdown above and ask questions in the chat!
</p>
</div>
</div>
</div>
);
}
This screen appears when no customer is selected, providing context on how agents work together.
Step 7: Start the Frontend Server
Now run the frontend:
# cd app/web
pnpm dev
This starts:
- Next.js frontend on
http://localhost:3000Open your browser to see the dashboard!
The frontend is ready. Now we need to build the backend agents that actually process customer requests and make decisions.
Backend Agent Service (LangGraph + CopilotKit SDK)
The /apps/agent directory contains the multi-agent backend that handles customer requests and coordinates responses.
The backend uses Node.js and TypeScript with LangGraph. If you're starting fresh, install the dependencies:
cd apps/agent
pnpm install
The key packages you need:
{
"dependencies": {
"@copilotkit/sdk-js": "1.10.6",
"@langchain/core": "^1.0.1",
"@langchain/langgraph": "1.0.2",
"@langchain/openai": "^1.1.3",
"zod": "^3.23.8"
}
}
Here's what each package does:
- @copilotkit/sdk-js - Integrates LangGraph workflows with CopilotKit's state streaming
- @langchain/langgraph - State machine framework for building agent workflows
- @langchain/core - Core abstractions (messages, runnables, tools)
-
@langchain/openai - LangChain wrapper for OpenAI models (I used:
gpt-5-nanoBut, any model you can use.) - zod - Schema validation for tool parameters
Define the Agent State
First, create src/types/state.ts. This defines the shared state that flows through all agents:
import { Annotation } from "@langchain/langgraph";
import { BaseMessage } from "@langchain/core/messages";
export const CustomerSupportStateAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
customers: Annotation<any[]>({
reducer: (x, y) => y ?? x,
default: () => [],
}),
currentCustomer: Annotation<{
found: boolean;
id?: string;
data?: any;
}>({
reducer: (x, y) => ({ ...x, ...y }),
default: () => ({ found: false }),
}),
intent: Annotation<{
category: string;
urgency: "low" | "medium" | "high";
confidence: number;
keywords: string[];
} | null>({
reducer: (x, y) => y ?? x,
default: () => null,
}),
escalation: Annotation<{
required: boolean;
reason?: string;
ticketId?: string;
assignedTo?: string;
priority?: number;
suggestAiFirst?: boolean;
} | null>({
reducer: (x, y) => y ?? x,
default: () => null,
}),
});
export type CustomerSupportState = typeof CustomerSupportStateAnnotation.State;
The reducer functions control how the state merges when agents update it, messages concatenates new entries to history, while intent and escalation replace their previous values with the latest results.
Build the Agent Tools
Agents need tools to perform specific actions. These are functions that agents can call to classify intent, look up customer data, or make escalation decisions. Let's build them.
Tool 1. Intent Classifier Tool
Create src/tools/intentClassifier.ts:
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
export const intentClassifierTool = tool(
async ({ message }) => {
const model = new ChatOpenAI({
model: "gpt-5-nano",
});
const prompt = `You are an intent classifier for telecom customer support.
Analyze this message and classify the intent:
"${message}"
Return JSON with:
{
"category": "billing_issue" | "service_outage" | "tech_support" | "cancellation" | "general_inquiry" | "upgrade_service",
"urgency": "low" | "medium" | "high",
"confidence": 0.0 to 1.0,
"keywords": ["keyword1", "keyword2"]
}`;
const response = await model.invoke([{ role: "user", content: prompt }]);
return response.content;
},
{
name: "classifyIntent",
description: "Classifies customer message intent and urgency level",
schema: z.object({
message: z.string().describe("The customer's message to classify"),
}),
}
);
This tool takes a customer message and returns the intent category, urgency level, and confidence score.
Tool 2: Customer Lookup Tool
Create src/tools/customerLookup.ts:
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { loadCustomerData } from "../utils/dataLoader";
export const customerLookupTool = tool(
async ({ customerId }) => {
const customers = await loadCustomerData();
const customer = customers.find(
(c) => c.customerID === customerId
);
if (!customer) {
return JSON.stringify({
found: false,
message: `Customer ${customerId} not found`,
});
}
return JSON.stringify({
found: true,
id: customer.customerID,
data: {
customerID: customer.customerID,
gender: customer.gender,
tenure: customer.tenure,
MonthlyCharges: customer.MonthlyCharges,
InternetService: customer.InternetService,
PhoneService: customer.PhoneService,
Churn: customer.Churn,
SeniorCitizen: customer.SeniorCitizen,
},
});
},
{
name: "lookupCustomer",
description: "Retrieve customer profile and service details by customer ID",
schema: z.object({
customerId: z.string().describe("Customer ID (e.g., '5575-GNVDE')"),
}),
}
);
This tool retrieves a customer's profile and service details by ID, returning account information such as tenure, charges, and active services.
Tool 3: Escalation Decision Tool
Create src/tools/escalationDecision.ts:
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
import { findCustomerById } from "../utils/dataLoader";
export const escalationDecisionTool = tool(
async ({ customerId, intent, urgency }) => {
const customer = customerId ? findCustomerById(customerId) : null;
const model = new ChatOpenAI({
model: "gpt-5-nano"
});
const customerContext = customer
? `Customer Profile:
- Churn Risk: ${customer.Churn}
- Tenure: ${customer.tenure} months
- Monthly Charges: $${customer.MonthlyCharges}
- Senior Citizen: ${customer.SeniorCitizen}`
: "No customer profile available";
const systemPrompt = `You are an escalation decision expert for telecom support.
${customerContext}
CURRENT ISSUE:
- Intent: ${intent}
- Urgency: ${urgency}
ESCALATION RULES:
1. ALWAYS ESCALATE if:
- Churn Risk = "Yes" (customer might cancel)
- Intent = "cancellation" (need retention specialist)
- Urgency = "high" (critical issues)
- Senior Citizen = "Yes" (need extra care)
- High-value customer (tenure > 50 months OR charges > $80)
2. SUGGEST AI FIRST if:
- Urgency = "high" BUT Churn Risk = "No"
- Intent = "service_outage", "tech_support", "internet_issue"
- Return "suggestAiFirst": true to offer AI suggestions before escalating
3. DEPARTMENT ASSIGNMENT:
- billing → "billing" team
- cancellation → "retention" team (PRIORITY 1)
- service_outage, tech_support → "tech" team
Return JSON:
{
"required": true/false,
"reason": "detailed reason",
"assignedTo": "billing" | "tech" | "retention",
"priority": 1 | 2 | 3,
"suggestAiFirst": true/false
}`;
const response = await model.invoke([
{ role: "system", content: systemPrompt },
{ role: "user", content: "Analyze if escalation is needed." },
]);
return response.content;
},
{
name: "checkEscalation",
description: "Determine if issue should be escalated to human agent",
schema: z.object({
customerId: z.string().optional(),
intent: z.string().describe("The classified intent category"),
urgency: z.enum(["low", "medium", "high"]),
}),
}
);
This tool determines whether an issue requires human escalation based on factors such as churn risk, urgency, and customer profile characteristics, including senior citizen status or high monthly charges.
Tool 4: Reply Generator Tool
Create src/tools/replyGenerator.ts:
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
export const replyGeneratorTool = tool(
async ({ customerData, intent, message }) => {
const model = new ChatOpenAI({
model: "gpt-5-nano",
});
const prompt = `You are a helpful telecom support assistant.
Customer: ${customerData}
Intent: ${intent}
Message: "${message}"
Generate a personalized, friendly response addressing their concern.`;
const response = await model.invoke([{ role: "user", content: prompt }]);
return response.content;
},
{
name: "generateReply",
description: "Generate personalized response based on customer context",
schema: z.object({
customerData: z.string().describe("Customer profile information"),
intent: z.string().describe("Classified intent category"),
message: z.string().describe("Customer's original message"),
}),
}
);
This tool generates personalized responses to customer queries based on their profile, intent, and conversation history.
With all tools ready, we can now connect the agents into a workflow that processes customer requests step by step.
Build the Multi-Agent Workflow
With tools and state defined, we need to orchestrate how agents work together. LangGraph uses a workflow model where you define the execution sequence, which agent runs first, what happens next, and how data flows between them.
Now, let's wire everything together in src/agent.ts:
import { StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
import { CustomerSupportStateAnnotation } from "./types/state";
// We'll create these agent nodes in the next section
import { intentAgentNode } from "./agents/intentAgent";
import { customerLookupAgentNode } from "./agents/customerLookupAgent";
import { escalationAgentNode } from "./agents/escalationAgent";
import { replyAgentNode } from "./agents/replyAgent";
// Create the workflow
const workflow = new StateGraph(CustomerSupportStateAnnotation);
// Add agent nodes(we'll build these next)
workflow.addNode("intent", intentAgentNode);
workflow.addNode("lookup", customerLookupAgentNode);
workflow.addNode("escalation", escalationAgentNode);
workflow.addNode("reply", replyAgentNode);
// Define the flow
workflow.addEdge(START, "intent");
workflow.addEdge("intent", "lookup");
workflow.addEdge("lookup", "escalation");
workflow.addEdge("escalation", "reply");
workflow.addEdge("reply", END);
// Compile with memory
const memory = new MemorySaver();
export const graph = workflow.compile({
checkpointer: memory,
});
StateGraph creates a workflow where nodes (your agents) are connected by edges that define execution order. Each edge routes one agent's output to the next, forming a pipeline: Intent → Lookup → Escalation → Reply.
The MemorySaver acts as a checkpointer, saving conversation state after each step so the system remembers previous messages across multiple turns.
Build the Agent Nodes
We've defined the workflow structure, but each node needs actual logic to process requests. Nodes are async functions that receive the current state, execute their specific task (like classifying intent or looking up customers), and return state updates. These updates then flow to the next node in the pipeline. Let's build them one by one.
Intent Agent Node
Create src/agents/intentAgent.ts:
import { RunnableConfig } from "@langchain/core/runnables";
import { CustomerSupportState } from "../types/state";
import { intentClassifierTool } from "../tools";
export async function intentAgentNode(
state: CustomerSupportState,
config: RunnableConfig
): Promise<Partial<CustomerSupportState>> {
console.log("Intent Agent: Classifying message...");
const messages = state.messages || [];
const lastMessage = messages[messages.length - 1];
if (!lastMessage || lastMessage._getType() === "ai") {
return { ...state };
}
const messageContent =
typeof lastMessage.content === "string"
? lastMessage.content
: JSON.stringify(lastMessage.content);
try {
const intentResult = await intentClassifierTool.invoke({
message: messageContent,
});
const intent = JSON.parse(intentResult);
console.log(`Intent classified: ${intent.category} (${intent.urgency})`);
return {
...state,
intent: intent,
};
} catch (error) {
console.error("Intent classification error:", error);
return {
...state,
intent: {
category: "general_inquiry",
urgency: "low",
confidence: 0.5,
keywords: [],
},
};
}
}
This node takes the last user message, classifies its intent and urgency, and stores it in state.intent.
Customer Lookup Agent Node
Create src/agents/customerLookupAgent.ts:
import { RunnableConfig } from "@langchain/core/runnables";
import { CustomerSupportState } from "../types/state";
import { customerLookupTool } from "../tools";
export async function customerLookupAgentNode(
state: CustomerSupportState,
config: RunnableConfig
): Promise<Partial<CustomerSupportState>> {
console.log("Lookup Agent: Retrieving customer data...");
const messages = state.messages || [];
const lastMessage = messages[messages.length - 1];
if (!lastMessage) {
return { ...state };
}
const messageContent =
typeof lastMessage.content === "string"
? lastMessage.content
: JSON.stringify(lastMessage.content);
// Extract customer ID from message (e.g., "5575-GNVDE")
const customerIdMatch = messageContent.match(/\\b\\d{4}-[A-Z]{5}\\b/);
if (!customerIdMatch) {
console.log("No customer ID found in message");
return {
...state,
currentCustomer: { found: false },
};
}
const customerId = customerIdMatch[0];
try {
const lookupResult = await customerLookupTool.invoke({ customerId });
const customer = JSON.parse(lookupResult);
console.log(`Customer found: ${customer.found}`);
return {
...state,
currentCustomer: customer,
};
} catch (error) {
console.error("Customer lookup error:", error);
return {
...state,
currentCustomer: { found: false },
};
}
}
This node extracts the customer ID from the message, looks it up, and stores the result in state.currentCustomer.
Escalation Agent Node
Create src/agents/escalationAgent.ts:
import { RunnableConfig } from "@langchain/core/runnables";
import { CustomerSupportState } from "../types/state";
import { escalationDecisionTool } from "../tools";
import { AIMessage } from "@langchain/core/messages";
import { Command } from "@langchain/langgraph";
export async function escalationAgentNode(
state: CustomerSupportState,
config: RunnableConfig
): Promise<Partial<CustomerSupportState>> {
console.log("Escalation Agent: Checking escalation criteria...");
const intent = state.intent?.category || "general_inquiry";
const urgency = state.intent?.urgency || "low";
const customerId = state.currentCustomer?.found
? state.currentCustomer.id
: undefined;
try {
const escalationResult = await escalationDecisionTool.invoke({
customerId,
intent,
urgency,
});
const escalation = JSON.parse(escalationResult);
if (escalation.required) {
console.log(`ESCALATION REQUIRED: ${escalation.reason}`);
const ticketId = `TKT-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 5)
.toUpperCase()}`;
const messages = state.messages || [];
const escalationMessage = new AIMessage({
content: `I've created a priority ticket (${ticketId}) for you. Our ${escalation.assignedTo} team will contact you shortly. ${escalation.reason}`,
});
return {
...state,
escalation: { ...escalation, ticketId },
messages: [...messages, escalationMessage],
};
} else if (escalation.suggestAiFirst) {
console.log("High urgency - offering AI suggestions first (HITL)");
const messages = state.messages || [];
const suggestionPrompt = new AIMessage({
content: `I understand this is urgent for you. I have some suggestions that might help resolve this quickly. Would you like me to share them with you before escalating to a human agent?`,
});
return Command.interrupt({
...state,
escalation: escalation,
messages: [...messages, suggestionPrompt],
});
} else {
console.log("No escalation needed - AI can handle this");
return {
...state,
escalation: escalation,
};
}
} catch (error) {
console.error("Escalation check error:", error);
return {
...state,
escalation: { required: false },
};
}
}
In this node:
- If escalation is required, → Creates a ticket and assigns it to the appropriate team (billing, tech, or retention). There's no ticket management UI in this implementation, but you could expand the system to include one.
- If urgency is high but churn is low, → AI assistance suggests some solutions before escalating
- If no escalation → Proceeds directly to the Reply Agent
Reply Agent Node
Create src/agents/replyAgent.ts:
import { RunnableConfig } from "@langchain/core/runnables";
import { CustomerSupportState } from "../types/state";
import { replyGeneratorTool } from "../tools";
import { AIMessage } from "@langchain/core/messages";
export async function replyAgentNode(
state: CustomerSupportState,
config: RunnableConfig
): Promise<Partial<CustomerSupportState>> {
console.log("Reply Agent: Generating response...");
const messages = state.messages || [];
const lastUserMessage = messages
.filter((m) => m._getType() === "human")
.pop();
if (!lastUserMessage) {
return { ...state };
}
const messageContent =
typeof lastUserMessage.content === "string"
? lastUserMessage.content
: "";
const customerData = state.currentCustomer?.found
? JSON.stringify(state.currentCustomer.data)
: "No customer data available";
const intent = state.intent?.category || "general_inquiry";
try {
const replyContent = await replyGeneratorTool.invoke({
customerData,
intent,
message: messageContent,
});
const replyMessage = new AIMessage({
content: replyContent,
});
return {
...state,
messages: [...messages, replyMessage],
};
} catch (error) {
console.error("Reply generation error:", error);
const fallbackMessage = new AIMessage({
content: "I apologize, but I'm having trouble generating a response right now. Please try again.",
});
return {
...state,
messages: [...messages, fallbackMessage],
};
}
}
This final node generates a personalized response based on customer data, intent, and the original message.
Run the Agent Server
Start the LangGraph server:
pnpm dev
This starts the agent on http://localhost:8123 using the LangGraph CLI.
All four agents work together as a single workflow, passing state between each step.
What You’ve Built
You now have a working multi-agent customer support system. The frontend handles the chat experience and shared state, while four focused agents handle classification, decision-making, and responses. CopilotKit ties the UI and agents together so actions taken by agents appear immediately in the interface, including escalations when a case needs human attention.
The full implementation, including error handling and logging, is available in the GitHub repository.
Wrapping Up
That’s the core idea behind this setup. Instead of hard-coding every step, you let agents coordinate through shared state and keep the UI in sync as work happens. When automation isn’t enough, the system naturally hands things off to a human.
This approach isn’t limited to customer support. The same structure works anywhere different agents need to act on the same data and surface their progress in a UI, whether that’s internal tools, operations dashboards, or workflow-heavy apps.
If you want to go further, you could layer in features such as ticket-tracking for escalations, integrations with external APIs, or lightweight reporting on agent activity.
Be sure to checkout the CopilotKit documentation and the example gallery.
Don't forget to follow CopilotKit on Twitter and join the Discord community of agent builders.
Happy building!




Top comments (2)
Hey Arindam, great tutorial. Building a multi-agent customer support system is pretty tricky, and you've put together a great boilerplate.
Finding examples using LangGraph JS is kind of hard to find so thank you for sharing!