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.
You’ll build a Zendesk ticket triage system that classifies incoming support tickets and routes them to specialized AI agents using AWS Bedrock. The webhook handler receives a new ticket, passes the description through a confidence-based classifier, and hands off to either a billing or tech-support agent — all wrapped in per-ticket budget controls to cap LLM spend. By the end you’ll have a working Next.js route that integrates @reaatech/confidence-router, @reaatech/agent-handoff, and @reaatech/agent-budget-engine with AWS Bedrock and the Zendesk API.
Prerequisites
Node.js >= 22
pnpm 10.x
An AWS account with Bedrock access (you need bedrock.amazonaws.com in your region)
A Zendesk account with API token access
Familiarity with TypeScript and Next.js App Router
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router and TypeScript.
When asked for the directory name, confirm. The scaffolder creates app/ and src/ in a split layout. The recipe uses src/ for services and app/ for routes.
Expected output:
code
cd aws-bedrock-triage && pnpm install
Step 2: Install dependencies
Navigate into the project, run the install the scaffolder suggests, then add the production and dev dependencies.
The ZENDESK_WEBHOOK_SECRET is the token Zendesk uses to sign each webhook payload. Copy it from your Zendesk admin panel under Apps and Integrations > Webhooks.
Step 4: Configure Next.js for the instrumentation hook
Create next.config.ts at the project root. The instrumentationHook: true flag is required — without it, src/instrumentation.ts never runs and the telemetry startup hooks are dead code.
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, } as NonNullable<NextConfig["experimental"]>,};export default nextConfig;
Step 5: Create the env module
The env module centralizes all env var reads. This means you can test with vi.stubEnv() and the real app uses process.env directly. Create src/lib/env.ts:
Define the agent types and ticket shape. This ensures the classifier, orchestrator, and webhook handler all agree on the data shapes. Create src/lib/types.ts:
ts
import type { HandoffPayload as BaseHandoffPayload, HandoffResult, Message } from "@reaatech/agent-handoff";import type { RoutingDecision as ConfidenceRoutingDecision } from "@reaatech/confidence-router";export enum AgentType { Billing = "billing", TechSupport = "tech_support", Human = "human",}export interface Ticket { id: number; subject: string; description: string; requesterId?: number | undefined; status: string; assignedGroup?: string | undefined; tags?: string[] | undefined;}export interface HandoffContextExtended { ticket: Ticket; history: Message[]; currentAgent: AgentType; metadata?: Record<string, unknown> | undefined;}export interface ClassifierPrediction { label: string; confidence: number;}export interface WebhookPayload { ticket?: { id: number } | undefined; id?: number | undefined;}export interface TriageResult { decision: ConfidenceRoutingDecision; agentResponse?: string | undefined;}export type { HandoffResult, Message };export type { ConfidenceRoutingDecision as RoutingDecision };export type { BaseHandoffPayload };
Step 7: Create the Zendesk client
The Zendesk client wraps node-zendesk and provides typed methods for fetching tickets, adding comments, and reassigning. It also tracks a singleton so the same client is reused across requests. Create src/lib/zendesk-client.ts:
The Bedrock client is a thin wrapper around the AWS SDK’s BedrockRuntimeClient. It reads the region from the env module and is imported by both the classifier and the agent files. Create src/lib/agents/client.ts:
ts
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";import { getEnv } from "../env.js";export const bedrockClient = new BedrockRuntimeClient({ region: getEnv().awsRegion });
Step 10: Create the budget engine integration
The budget module wires BudgetController with the spend tracker and pricing provider. It exposes getOrDefineBudgetForTicket to set up a budget scope per ticket, checkBudgetBeforeCall for pre-flight checks, and recordSpendAfterCall to log usage after each Bedrock call. Create src/lib/budget.ts:
The billing agent calls Bedrock with a system prompt that sets the billing-specialist context, checks the budget before sending, and records spend after the response. Create src/lib/agents/billing-agent.ts:
ts
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";import { bedrockClient } from "./client.js";import { checkBudgetBeforeCall, recordSpendAfterCall } from "../budget.js";import { getEnv } from "../env.js";import type { BaseHandoffPayload } from "../types.js";export class AgentInvocationError extends Error { constructor(message: string, cause?: unknown) { super(message); this.name = "AgentInvocationError"; this.cause = cause; }}export async function invokeBillingAgent( payload: BaseHandoffPayload): Promise<{ response: string; usage: { inputTokens: number; outputTokens: number } }> { const ticketSubject = String(payload.customData?.subject ?? ""); const ticketDescription = String(payload.customData?.description ?? ""); const modelId = getEnv().bedrockModelId; const estimatedCost = 0.02; const ticketId = String(payload.customData?.ticketId ?? "unknown"); const budgetCheck = await checkBudgetBeforeCall(ticketId, estimatedCost, modelId, []); if (!budgetCheck.allowed) { throw new AgentInvocationError("Budget check failed: " + budgetCheck.action); } const command = new ConverseCommand({ modelId, messages: [ { role: "user", content: [{ text: `Subject: ${ticketSubject}\nDescription: ${ticketDescription}` }], }, ], system: [{ text: "You are a billing support specialist. Review the ticket and provide resolution guidance or ask clarifying questions." }], inferenceConfig: { maxTokens: 1024 }, }); try { const result = await bedrockClient.send(command); const text = result.output?.message?.content?.[0]?.text ?? ""; const inputTokens = result.usage?.inputTokens ?? 0; const outputTokens = result.usage?.outputTokens ?? 0; await recordSpendAfterCall(ticketId, `billing-${Date.now()}`, estimatedCost, inputTokens, outputTokens, modelId); return { response: text, usage: { inputTokens, outputTokens } }; } catch (error) { throw new AgentInvocationError("Billing agent Bedrock call failed", error); }}
Step 12: Create the tech support agent
The tech support agent follows the same pattern but with a different system prompt. Create src/lib/agents/tech-support-agent.ts:
ts
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";import { bedrockClient } from "./client.js";import { checkBudgetBeforeCall, recordSpendAfterCall } from "../budget.js";import { getEnv } from "../env.js";import type { BaseHandoffPayload } from "../types.js";import { AgentInvocationError } from "./billing-agent.js";export async function invokeTechSupportAgent( payload: BaseHandoffPayload): Promise<{ response: string; usage: { inputTokens: number; outputTokens: number } }> { const ticketSubject = String(payload.customData?.subject ?? ""); const ticketDescription = String(payload.customData?.description ?? ""); const modelId = getEnv().bedrockModelId; const estimatedCost = 0.02; const ticketId = String(payload.customData?.ticketId ?? "unknown"); const budgetCheck = await checkBudgetBeforeCall(ticketId, estimatedCost, modelId, []); if (!budgetCheck.allowed) { throw new AgentInvocationError("Budget check failed: " + budgetCheck.action); } const command = new ConverseCommand({ modelId, messages: [ { role: "user", content: [{ text: `Subject: ${ticketSubject}\nDescription: ${ticketDescription}` }], }, ], system: [{ text: "You are a technical support specialist. Review the ticket and provide troubleshooting steps or resolution guidance." }], inferenceConfig: { maxTokens: 1024 }, }); try { const result = await bedrockClient.send(command); const text = result.output?.message?.content?.[0]?.text ?? ""; const inputTokens = result.usage?.inputTokens ?? 0; const outputTokens = result.usage?.outputTokens ?? 0; await recordSpendAfterCall(ticketId, `tech-${Date.now()}`, estimatedCost, inputTokens, outputTokens, modelId); return { response: text, usage: { inputTokens, outputTokens } }; } catch (error) { throw new AgentInvocationError("Tech support agent Bedrock call failed", error); }}
Step 13: Create the classifier
The classifier uses ConfidenceRouter with a KeywordClassifier as a fallback and Bedrock as the primary classifier. The router’s decide method returns ROUTE, FALLBACK, or CLARIFY based on confidence thresholds. Create src/lib/classifier.ts:
ts
import { ConfidenceRouter, KeywordClassifier } from "@reaatech/confidence-router";import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";import { bedrockClient } from "./agents/client.js";import { getEnv } from "./env.js";import { checkBudgetBeforeCall, recordSpendAfterCall } from "./budget.js";import type { ClassifierPrediction, RoutingDecision } from "./types.js";const router = new ConfidenceRouter({ routeThreshold: 0.8, fallbackThreshold: 0.3, clarificationEnabled: false,});router.registerClassifier( new KeywordClassifier([ { label: "billing", keywords: ["refund", "charge", "invoice", "payment"] }, { label: "tech_support", keywords: ["error", "bug", "not working", "crash", "broken"] }, { label: "account", keywords: ["password", "login", "access", "account"] }, ]));async function classifyTicketWithBedrock(text: string): Promise<ClassifierPrediction[]> { const modelId = getEnv().bedrockModelId; const estimatedCost = 0.01; const ticketId = `classifier-${Date.now()}`; const budgetCheck = await checkBudgetBeforeCall(ticketId, estimatedCost, modelId, []); if (!budgetCheck.allowed) { throw new Error("Budget check failed for classifier"); } const command = new ConverseCommand({ modelId, messages: [ { role: "user", content: [ { text: `Classify the following support ticket into one or more categories: billing, tech_support, account, other. Return a JSON array of objects with keys "label" and "confidence" (0-1).\n\nTicket: ${text}`, }, ], }, ], inferenceConfig: { maxTokens: 256 }, }); const result = await bedrockClient.send(command); const responseText = result.output?.message?.content?.[0]?.text ?? "[]"; const inputTokens = result.usage?.inputTokens ?? 0; const outputTokens = result.usage?.outputTokens ?? 0; await recordSpendAfterCall(ticketId, `classifier-${Date.now()}`, estimatedCost, inputTokens, outputTokens, modelId); const parsed = JSON.parse(responseText) as unknown; if (!Array.isArray(parsed)) { throw new Error("Invalid classifier response: expected array"); } const predictions: ClassifierPrediction[] = []; for (const item of parsed) { if ( item !== null && typeof item === "object" && "label" in item && "confidence" in item && typeof item.label === "string" && typeof item.confidence === "number" ) { predictions.push({ label: item.label, confidence: item.confidence }); } } return predictions;}export async function determineRouting(ticketText: string): Promise<RoutingDecision> { try { const predictions = await classifyTicketWithBedrock(ticketText); return router.decide({ predictions }); } catch { const keywordResult = await router.classify(ticketText); if (keywordResult.predictions.length === 0) { return { type: "FALLBACK", confidence: 0, }; } return router.decide(keywordResult); }}
Step 14: Create the agent handoff orchestrator
The HandoffManager uses TypedEventEmitter for logging and withRetry for resilience. It builds a HandoffPayload from the ticket data, dispatches to either invokeBillingAgent or invokeTechSupportAgent, and escalates to a human agent on failure. Create src/lib/orchestrator.ts:
ts
import { HandoffPayload, HandoffResult, TypedEventEmitter, withRetry, createHandoffConfig, TransportError, type AgentCapabilities,} from "@reaatech/agent-handoff";import { AgentType, type Ticket } from "./types.js";import { getZendeskClient } from "./zendesk-client.js";import pino from "pino";import { invokeBillingAgent } from "./agents/billing-agent.js";import { invokeTechSupportAgent } from "./agents/tech-support-agent.js";const logger = pino({ name: "orchestrator" });interface
Step 15: Create the webhook route handler
The webhook endpoint verifies the Zendesk signature using a timing-safe comparison, extracts the ticket ID from the payload, fetches the full ticket via the Zendesk client, runs the classifier, and dispatches the handoff. Create src/app/api/webhooks/zendesk/route.ts:
ts
import { type NextRequest, NextResponse } from "next/server.js";import { createHmac, timingSafeEqual } from "crypto";import { getZendeskClient } from "../../../../lib/zendesk-client.js";import { determineRouting } from "../../../../lib/classifier.js";import { HandoffManager } from "../../../../lib/orchestrator.js";import { getOrDefineBudgetForTicket } from "../../../../lib/budget.js";import { AgentType } from "../../../../lib/types.js";import { getEnv } from "../../../../lib/env.js";import pino from "pino";const logger = pino({ name: "webhook" });
Step 16: Add the instrumentation hook
Create src/instrumentation.ts at the project root (same level as next.config.ts). This runs once when the Next.js server starts. The register() function is called before any request handlers, so it’s the right place to eagerly initialize the Zendesk client and Bedrock client so the first request doesn’t pay the cold-start cost.
Register this webhook URL in your Zendesk admin panel under Apps and Integrations > Webhooks. Point it at https://your-domain.com/api/webhooks/zendesk and select the ticket.created trigger.
Add a Slack or PagerDuty notification in the HandoffManagerhandoff-failed handler so your team knows when escalation fires.
Wire up @reaatech/agent-budget-spend-tracker with a persistent store (Redis or DynamoDB) so budget state survives server restarts during long-running tickets.
OrchestratorEvents
{
"handoff-start": { targetAgent: AgentType; ticketId: number };