Small support teams drown in Freshdesk tickets, missing SLAs because manual triage can't keep up with volume. Tickets bounce between assignee groups, context is lost, and customers wait.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building a multi-agent ticket triage system that listens for Freshdesk webhook events, classifies each ticket’s intent using Anthropic’s Claude, and either routes the ticket to a specialist AI agent, requests clarification, or escalates to a human support agent. You’ll wire together @reaatech/agent-handoff, @reaatech/confidence-router, @reaatech/session-continuity, and LangGraph to create a triage pipeline with a Next.js API route and a standalone Express webhook server.
This recipe is for TypeScript developers who want to see the REAA agent-handoff ecosystem in action with real LLM calls, session management, and confidence-based routing.
Prerequisites
Node.js 22+ and pnpm 10+
An Anthropic API key for Claude (set as ANTHROPIC_API_KEY)
A Freshdesk account (subdomain + API key) — or use the mock tests to develop offline
A Langfuse account for observability (optional; the code creates a client but doesn’t hard-fail without it)
Familiarity with Next.js App Router, Express, and TypeScript
Step 1: Scaffold the project and install dependencies
Start with an empty directory and create a Next.js 16 project with the App Router. The scaffold includes package.json, tsconfig.json, next.config.ts, and the app/ directory. Once it’s in place, install the REAA packages and runtime dependencies.
Expected output: Each pnpm add exits with 0. A node_modules/ folder and pnpm-lock.yaml appear. Your package.json now lists all dependencies with exact versions (no ^ or ~ prefixes).
Create the .env.example file so the recipe knows which environment variables to expect:
env
# Env vars used by anthropic-multi-agent-handoff-for-freshdesk-smb-support-triage.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentANTHROPIC_API_KEY=<your-anthropic-key>FRESHDESK_DOMAIN=<your-freshdesk-subdomain>FRESHDESK_API_KEY=<your-freshdesk-api-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>API_KEY=<your-mesh-gateway-api-key>PORT=3000
Expected output: A file at .env.example with these eight placeholder entries. Never commit real values.
Step 2: Define shared types
You’ll need a set of TypeScript types shared across every module — ticket shapes, classification output, triage metrics, and the triage result. Create src/lib/types.ts:
Expected output: A clean src/lib/types.ts with six interfaces, three type aliases, and two re-exports from the REAA packages. TypeScript should not complain when you run pnpm typecheck later.
Step 3: Build the Freshdesk API adapter
The adapter wraps the Freshdesk v2 REST API so the rest of the system can read tickets, create private notes, and update ticket fields. Create src/lib/freshdesk-adapter.ts:
ts
import type { FreshdeskTicketEvent } from "./types.js";export interface FreshdeskConfig { domain: string; apiKey: string;}const domain = process.env.FRESHDESK_DOMAIN ?? "";const apiKey = process.env.FRESHDESK_API_KEY ?? "";const baseUrl = `https://${domain}.freshdesk.com/api/v2`;const authHeader = `Basic ${Buffer.from(`x:${apiKey}`).toString("base64")}`;class FreshdeskError extends Error { constructor( public statusCode: number, public bodyText: string, ) { super(`Freshdesk API error: ${String(statusCode)}`); }}async function request<T>(method: string, path: string, body?: unknown): Promise<T> { const url = `${baseUrl}${path}`; const headers: Record<string, string> = { Authorization: authHeader, "Content-Type": "application/json", }; const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const text = await res.text(); throw new FreshdeskError(res.status, text); } if (res.status === 204) { return undefined as T; } return res.json() as Promise<T>;}export async function getTicket(ticketId: number): Promise<FreshdeskTicketEvent> { return request<FreshdeskTicketEvent>("GET", `/tickets/${String(ticketId)}`);}export async function createNote( ticketId: number, body: string, isPrivate: boolean,): Promise<{ id: number }> { return request<{ id: number }>("POST", `/tickets/${String(ticketId)}/notes`, { body, private: isPrivate, });}export async function updateTicket( ticketId: number, updates: { priority?: number; status?: number; group_id?: number },): Promise<void> { return request<undefined>("PUT", `/tickets/${String(ticketId)}`, updates);}
Expected output: Three exported async functions (getTicket, createNote, updateTicket) plus an internal request helper that handles Basic auth, error reporting, and 204 no-content responses.
Step 4: Create the ticket classifier with Claude and keyword fallback
The classifier has two layers: Claude for high-accuracy JSON classification, and a keyword-based fallback that runs when the API is unreachable. Create src/lib/classifier.ts:
ts
import Anthropic from "@anthropic-ai/sdk";import type { ClassificationInput, ClassificationOutput } from "./types.js";const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY,});export async function classifyWithClaude(input: ClassificationInput): Promise<ClassificationOutput> { const system = "You are a support ticket classifier. Analyze the subject and description and return a JSON object with keys: intent (one of: billing, technical, account, general, emergency), confidence (number 0-1), language (ISO 639-1 code), subIntent (optional string)."; const response = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 256
Expected output: A file with three exports. classifyTicket is the main entry — it tries Claude first and falls back to keyword matching if the API call throws. The fallback maps keywords like “invoice” to billing, “error” to technical, “password” to account, and “urgent” to emergency, with a heuristic confidence formula.
Step 5: Build the LangGraph triage graph
The triage graph uses @langchain/langgraph to orchestrate a five-node workflow: classify the ticket, route based on confidence, select a specialist, escalate to human, or ask for clarification. Create src/lib/triage-graph.ts:
ts
import { StateGraph, Annotation, MessagesAnnotation } from "@langchain/langgraph";import type { NormalizedTicket, ClassificationOutput } from "./types.js";import { classifyTicket } from "./classifier.js";import { ConfidenceRouter } from "@reaatech/confidence-router";import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";void MessagesAnnotation;const router = new ConfidenceRouter({ routeThreshold: 0.8, fallbackThreshold: 0.3, clarificationEnabled: true,});const specialistRouter = new CapabilityBasedRouter({
Expected output: A compiled LangGraph state machine with five nodes (classify, route, selectSpecialist, escalate, clarify) and conditional edges that mirror the decision tree: classify confidence >= 0.3 goes to route; route decisions dispatch to specialist/clarify/escalate; and the selectSpecialist node uses CapabilityBasedRouter to match against three registered agents.
Step 6: Implement the triage agent with session continuity
The TriageAgent orchestrates the full flow: it opens a session via @reaatech/session-continuity, invokes the LangGraph triage graph (with retry via @reaatech/agent-handoff’s withRetry), and emits typed events at each lifecycle stage. Create src/lib/triage-agent.ts:
ts
import { SessionManager } from "@reaatech/session-continuity";import { createHandoffConfig, TypedEventEmitter, withRetry,} from "@reaatech/agent-handoff";import Langfuse from "langfuse";import type { Message,} from "@reaatech/agent-handoff";import type { IStorageAdapter, TokenCounter, Session, SessionId, Message as SessionMessage, MessageId, UpdateSessionOptions, MessageQueryOptions, SessionFilters, HealthStatus,} from "@reaatech/session-continuity";
Expected output: The TriageAgent class with a constructor that sets up in-memory session storage, a SessionManager with a 4096-token budget and sliding-window compression, a typed event emitter, and a createHandoffConfig. The runTriage method creates a session, invokes the LangGraph graph with exponential-backoff retry, and returns a TriageResult with one of three actions: route, clarify, or fallback.
Step 7: Create the human escalation module
When confidence is too low, the system escalates by compressing the conversation context and posting a private note to the Freshdesk ticket. Create src/lib/human-escalation.ts:
Expected output: Two exported functions. createEscalationSummary compresses conversation history using HybridCompressor (max 2000 tokens, preserving the 3 most recent messages). escalateToHuman builds a formatted private note from the compressed context and posts it back to Freshdesk, returning success: true with the Freshdesk note ID.
Step 8: Set up the Express Freshdesk webhook server
Freshdesk sends ticket-created and ticket-updated events to an HTTP endpoint. This Express server listens for those events, normalizes them, runs triage, and escalates when confidence is low. Create src/api/freshdesk/webhook.ts:
ts
import express from "express";import type { Request, Response } from "express";import { authMiddleware, tlsMiddleware, rateLimiterMiddleware, healthCheck, deepHealthCheck, handleRequest,} from "@reaatech/agent-mesh-gateway";import type { FreshdeskTicketEvent, NormalizedTicket, TicketPriority } from "../../lib/types.js";import { TriageAgent } from "../../lib/triage-agent.js";import { escalateToHuman } from "../../lib/human-escalation.js";export const freshdeskWebhookApp = express();freshdeskWebhookApp.set("trust proxy",
Expected output: An Express app wired with TLS enforcement, JSON body parsing, rate limiting, health check endpoints, and the agent-mesh-gateway’s /v1/request handler. The /webhook/freshdesk/ticket POST route normalizes incoming Freshdesk ticket events, runs triage, and escalates to human if the triage action is fallback.
Step 9: Build the Next.js API routes
Create the main triage endpoint so the Next.js app itself can also accept classification requests. Create app/api/triage/route.ts:
Expected output: Two Next.js App Router route handlers. POST /api/triage accepts { subject, description } and returns the routing decision from the ConfidenceRouter. POST /api/webhook/freshdesk accepts Freshdesk webhook payloads, normalizes them, runs triage, and returns { received: true }. Both use NextRequest/NextResponse with NextResponse.json().
Step 10: Write and run the tests
You need unit tests for each module. The test files mock external SDKs so no API calls or database connections happen during the test run.
Create tests/classifier.test.ts. The file tests all three classifier exports across multiple edge cases — here are the key test groups (the full file is longer):
Create tests/triage-route.test.ts (excerpt — the full file has more cases):
ts
import { NextRequest } from "next/server";const { mockClassifyTicket, mockDecide } = vi.hoisted(() => ({ mockClassifyTicket: vi.fn(), mockDecide: vi.fn(),}));vi.mock("@/src/lib/classifier.js", () => ({ classifyTicket: mockClassifyTicket,}));vi.mock("@reaatech/confidence-router", () => ({ ConfidenceRouter: vi.fn().mockImplementation(function() { return { decide: mockDecide }; }),}));import { POST, GET } from "../app/api/triage/route.js";function makeReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/triage", { method: "POST", body: JSON.stringify(body), });}describe("POST /api/triage", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns action and confidence for valid request", async () => { mockClassifyTicket.mockResolvedValue({ intent: "billing", confidence: 0.92, language: "en" }); mockDecide.mockReturnValue({ type: "ROUTE", target: "billing-agent" }); const res = await POST(makeReq({ subject: "refund", description: "I want my money back" })); const text = await res.text(); const json = JSON.parse(text) as { action?: string; confidence?: number; error?: string }; expect(res.status).toBe(200); expect(json.action).toBe("ROUTE"); expect(json.confidence).toBe(0.92); }); it("returns 400 for empty body", async () => { const res = await POST(makeReq({})); expect(res.status).toBe(400); const text = await res.text(); const json = JSON.parse(text) as { error?: string }; expect(json.error).toContain("required"); }); // ... additional tests cover missing subject/description, // classifier throwing, and GET returning ok status});
Now run the test suite to verify everything works:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:pnpm typecheck exits 0 with no TypeScript errors. pnpm lint exits 0 with no ESLint violations. pnpm test prints a summary showing numFailedTests=0 across all test files (classifier, triage-graph, triage-agent, triage-route, freshdesk-adapter, freshdesk-route, human-escalation, webhook, and index), with coverage metrics at or above 90%. The vitest-report.json file is written to the project root.
Next steps
Add a Postgres-backed storage adapter — replace MapStorageAdapter with a real database using IStorageAdapter so session state survives server restarts.
Wire in Langfuse observability — the recipe creates a Langfuse client but doesn’t log spans yet. Pass it into the SessionManager and TriageAgent for full trace visibility.
Add more specialist agents — register agents for product-specific skills (e.g., “shipping-agent” with skills ["shipping", "tracking", "returns"]) to handle ecommerce SMB use cases.
Deploy the Express webhook — run the Express server as a separate process behind a reverse proxy so Freshdesk can send events to it independently of the Next.js app.