A 20-person e-commerce support team is drowning in Slack DMs and shared inbox emails. Support agents spend the first hour each day manually sorting messages by urgency. Critical customer issues (P0) get buried under low-priority chatter, leading to SLA breaches and churn. The VP of Support needs an automated triage system that flags urgent items and drafts contextual replies.
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.
In this tutorial you’ll build an automated triage agent that reads inbound Slack messages and Gmail emails, classifies each as P0 (urgent), P1 (high priority), or P2 (general), and drafts contextual AI replies for urgent items. By the end, you’ll have a fully tested Next.js API that wires five @reaatech/* packages — intent classification, confidence-gated routing, agent registry, budget-aware LLM gating, and circuit breakers — into a triage pipeline.
Prerequisites
Node.js 22+ and pnpm 10+ installed on your machine
A Slack workspace (for testing webhook events — you can run the tests without one)
A Gmail inbox (for email polling — tests run fully offline)
An OpenAI API key (or any OpenAI-compatible provider) for LLM draft generation
Basic familiarity with TypeScript and Next.js App Router concepts
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router, TypeScript, and ESM.
Expected output: All 11 runtime dependencies are installed alongside react, react-dom, and next (already present from scaffolding). pnpm-lock.yaml records the exact versions.
Step 2: Create the shared types
Define the core data structures and error classes that flow through the pipeline. Create src/lib/types.ts:
The four error classes at the end (ClassificationError, GmailError, SlackError, BudgetExhaustedError) let downstream services throw typed errors that route handlers can catch and translate into appropriate HTTP status codes.
Step 3: Validate environment variables with Zod
Environment variables are the only source of runtime configuration. Use Zod to validate them eagerly at import time. Create src/lib/env.ts:
The schema defines 6 required vars (SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, GMAIL_CLIENT_EMAIL, GMAIL_PRIVATE_KEY, GMAIL_USER_EMAIL, OPENAI_API_KEY) and the rest with smart defaults. In test mode, missing vars get automatically filled with placeholder values so you don’t need a real .env to run tests.
Expected output: When you run pnpm typecheck, this module compiles without errors.
Step 4: Build the config object
Create src/lib/config.ts to derive a typed TriageConfig from the validated env:
The Langfuse config is only included when all three keys are present — omit any of them and the observability layer is silently disabled.
Expected output: TypeScript compiles. You can verify the config structure by running pnpm typecheck.
Step 5: Create the OpenAI LLM client
The LLM client wraps the OpenAI SDK with a configurable baseURL — this is what makes the provider agnostic. Create src/services/llm-client.ts:
ts
import OpenAI from "openai";import { config } from "@/src/lib/config.js";const llmClient = new OpenAI({ apiKey: config.llm.apiKey, baseURL: config.llm.baseUrl,});const systemPrompt = "You are a support-triage assistant. Draft a reply to the following customer message. Match the urgency: P0 = urgent crisis response, P1 = helpful troubleshooting, P2 = brief acknowledgment. Be concise.";async function generateDraftReply(prompt: string): Promise<string> { try { const response = await llmClient.chat.completions.create({ model: config.llm.model, messages: [ { role: "developer", content: systemPrompt }, { role: "user", content: prompt }, ], max_tokens: 500, }); return response.choices[0]?.message?.content ?? ""; } catch (error: unknown) { if (error instanceof OpenAI.APIError) { console.error("OpenAI API error:", error.message, error.status); } return "[Draft unavailable]"; }}export { llmClient, generateDraftReply };
Setting baseURL to an arbitrary endpoint is the key — swap OPENAI_BASE_URL to any provider that serves an OpenAI-compatible API and the recipe works unchanged. The catch block returns "[Draft unavailable]" for any error (rate limits, network failures, API errors) instead of crashing the pipeline.
Expected output: When called with a mocked HTTP response, generateDraftReply returns the model’s reply text.
Step 6: Set up observability with Langfuse
Create src/lib/observability.ts for optional tracing. Langfuse is only initialized when all three env vars are set, so the recipe works without it:
The no-op shim returned when Langfuse is absent means calling code never has to check for null — it just calls .span(), .end(), etc. on a mock object.
Step 7: Wire the REAA agent registry
This is where the five @reaatech/* packages start coming together. Create src/services/agent-registry-service.ts to register four triage agents using @reaatech/agent-handoff-routing:
The four agents map to the four triage outcomes: P0-specialist for urgent incidents, P1-specialist for troubleshooting and billing, P2-generalist for FAQs, and escalation-manager for SLA breach escalations.
Step 8: Create the intent classification service
The classification service uses @reaatech/agent-mesh-classifier to detect language and classify messages, then passes the output through @reaatech/agent-mesh-confidence’s confidence gate. Create src/services/classification-service.ts:
ts
import { classifierService, isRateLimitError, detectLanguage,} from "@reaatech/agent-mesh-classifier";import { evaluateConfidenceGate, generateClarificationQuestion,} from "@reaatech/agent-mesh-confidence";import type { InboundMessage } from "@/src/lib/types.js";import { ClassificationError } from "@/src/lib/types.js";const meshRegistry = [ { agent_id: "p0-specialist", display_name: "Urgent Triage (P0)", description: "Handles P0 urgent incidents, outages, security issues", endpoint: "http://localhost:8000/agents/p0-specialist", type: "mcp"
The pipeline is: detect language → classify against the mesh → evaluate confidence → either route, clarify, or fall back. Rate-limit errors are caught cleanly and degraded to a P2 fallback.
Step 9: Set up budget-aware LLM gating
The budget service gates LLM draft generation using @reaatech/agent-budget-llm-router-plugin. Create src/services/budget-service.ts:
ts
import { BudgetAwareStrategy } from "@reaatech/agent-budget-llm-router-plugin";import { BudgetController } from "@reaatech/agent-budget-engine";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetScope } from "@reaatech/agent-budget-types";import type { BudgetPolicy } from "@reaatech/agent-budget-types";import { config } from "@/src/lib/config.js";const store = new SpendStore();const controller = new BudgetController({ spendTracker: store });const policy: BudgetPolicy = { softCap: 0.8, hardCap: 1.0, autoDowngrade: [], disableTools: [],};controller.defineBudget({ scopeType: BudgetScope.User, scopeKey: "default", limit: config.budgetLimitUsd, policy,});const budgetStrategy = new BudgetAwareStrategy({ controller, defaultScopeType: BudgetScope.User,});function isBudgetAvailable(modelId: string = config.llm.model): boolean { const result = budgetStrategy.select({ scopeKey: "default", models: [{ id: modelId, estimatedCost: 0.02 }], }); return !result.blocked;}export { isBudgetAvailable, budgetStrategy, controller };
This instantiates an in-memory spend tracker, a budget controller that enforces the configured USD limit with 80% soft cap and 100% hard cap, and a BudgetAwareStrategy that checks remaining budget before each LLM call. When isBudgetAvailable() returns false, the triage engine skips the LLM call and returns a template instead.
Expected output: When budget is sufficient, isBudgetAvailable() returns true. When spend reaches the hard cap, it returns false.
Step 10: Wire the circuit breaker
The circuit breaker prevents cascading failures by isolating the classifier when errors pile up. Create src/services/circuit-breaker-service.ts:
The Slack client wraps @slack/web-api’s WebClient to read channel history and post messages. Create src/services/slack-client.ts:
ts
import { WebClient, ErrorCode } from "@slack/web-api";import { config } from "@/src/lib/config.js";import type { InboundMessage } from "@/src/lib/types.js";import { SlackError } from "@/src/lib/types.js";const slackClient = new WebClient(config.slack.token);interface SlackMessage { user?: string; text?: string; ts?: string; thread_ts?: string;}async function fetchRecentMessages(channelId: string, limit = 100): Promise<InboundMessage[]> { try { const result = await slackClient.conversations.history({ channel: channelId, limit }); const messages = (result.messages ?? []) as SlackMessage[]; return messages.map((msg) => ({ id: `slack:${msg.ts ?? ""}`, source: "slack" as const, from: msg.user ?? "unknown", body: msg.text ?? "", timestamp: msg.ts ?? "", channel: channelId, threadTs: msg.thread_ts, })); } catch (error: unknown) { if (isSlackPlatformError(error)) { console.error("Slack platform error:", error.data?.error); } throw new SlackError("Failed to fetch messages", error); }}function isSlackPlatformError( error: unknown,): error is { code: typeof ErrorCode.PlatformError; data?: { error?: string } } { return ( typeof error === "object" && error !== null && "code" in error && (error).code === ErrorCode.PlatformError );}async function postMessage(channelId: string, text: string): Promise<string> { const result = await slackClient.chat.postMessage({ channel: channelId, text }); return result.ts as string;}async function postReplyInThread(channelId: string, threadTs: string, text: string): Promise<void> { await slackClient.chat.postMessage({ channel: channelId, text, thread_ts: threadTs });}export { slackClient, fetchRecentMessages, postMessage, postReplyInThread };
Step 12: Create the Slack event parser
The Slack Events API sends different payload shapes: url_verification challenges, event_callback messages, and bot messages you want to skip. Create src/services/slack-events.ts:
The Gmail client uses googleapis to authenticate with a JWT service account, fetch unread messages, parse MIME bodies, and mark them as read. Create src/services/gmail-client.ts:
ts
import { google } from "googleapis";import { config } from "@/src/lib/config.js";import type { InboundMessage } from "@/src/lib/types.js";import { GmailError } from "@/src/lib/types.js";function getGmailClient() { const jwtClient = new google.auth.JWT({ email: config.gmail.clientEmail, key: config.gmail.privateKey.replace(/\\n/g, "\n"), scopes: ["https://www.googleapis.com/auth/gmail.modify"], subject: config.gmail.userEmail, }); return google.gmail({ version: "v1", auth: jwtClient });}
The extractBody function handles both single-part and multipart MIME payloads, preferring text/plain and falling back to text/html with stripped tags.
Step 14: Build the core triage engine
Now the main piece — the TriageEngine class ties everything together. Create src/services/triage-engine.ts:
ts
import { classifyMessage } from "@/src/services/classification-service.js";import { isBudgetAvailable } from "@/src/services/budget-service.js";import { isAgentReady, recordAgentSuccess, recordAgentFailure } from "@/src/services/circuit-breaker-service.js";import { generateDraftReply } from "@/src/services/llm-client.js";import type { InboundMessage, Priority, TriageResult } from "@/src/lib/types.js";function buildDraftPrompt(message: InboundMessage, priority: Priority): string { return `Customer message:\n${message.body}\n\nPriority: ${priority}\n\nDraft a reply as a support agent.`;}function mapAgentToPriority(agentId:
The processMessage method walks through a strict pipeline: circuit breaker gate → intent classification + confidence gate → budget check → draft generation → circuit breaker recording. processBatch handles messages sequentially to avoid rate-limiting external APIs.
Step 15: Create the Slack inbound webhook
The Slack route handler verifies HMAC-SHA256 signatures, parses event types, invokes the triage engine, and posts replies. Create app/api/inbound/slack/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import crypto from "crypto";import { config } from "@/src/lib/config.js";import { parseSlackEventBody } from "@/src/services/slack-events.js";import { TriageEngine } from "@/src/services/triage-engine.js";import { postReplyInThread, postMessage } from "@/src/services/slack-client.js";import type { InboundMessage } from "@/src/lib/types.js";function verifySlackSignature( body: string, signature: string, timestamp: string,): boolean { const sigBase = `v0:${timestamp}:${body}`; const expected = crypto .createHmac("sha256", config.slack.signingSecret) .update(sigBase) .digest("hex"); const sig = `v0=${expected}`; if (sig.length !== signature.length) return false; try { return crypto.timingSafeEqual(Buffer.from(sig, "utf-8"), Buffer.from(signature, "utf-8")); } catch { return false; }}function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);}function parseJsonObject(raw: string): Record<string, unknown> { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) { throw new Error("Expected a JSON object"); } return parsed;}export async function POST(req: NextRequest): Promise<NextResponse> { try { const rawBody = await req.text(); const signature = req.headers.get("x-slack-signature") ?? ""; const timestamp = req.headers.get("x-slack-request-timestamp") ?? ""; if (!verifySlackSignature(rawBody, signature, timestamp)) { return NextResponse.json({ error: "invalid signature" }, { status: 401 }); } const body = parseJsonObject(rawBody); const parsed = parseSlackEventBody(body); if (parsed.type === "url_verification") { return NextResponse.json({ challenge: parsed.challenge }); } if (parsed.type === "message") { const message: InboundMessage = { id: `slack:${parsed.event.ts}`, source: "slack", from: parsed.event.user, body: parsed.event.text, timestamp: parsed.event.ts, channel: parsed.event.channel, threadTs: parsed.event.thread_ts, }; const engine = new TriageEngine(); const result = await engine.processMessage(message); if (result.draftReply) { await postReplyInThread(parsed.event.channel, parsed.event.ts, result.draftReply); } if (result.priority === "P0") { const channel = config.slack.p0Channel ?? parsed.event.channel; await postMessage(channel, `P0 alert: ${result.reason}`); } } return NextResponse.json({ ok: true }); } catch { return NextResponse.json({ ok: true }); }}
The handler reads the raw body as text (not JSON) so the HMAC is computed against the exact byte sequence. timingSafeEqual prevents timing side-channel attacks on signature comparison. The catch block always returns { ok: true } so Slack doesn’t retry on malformed events.
Step 16: Create the email poll endpoint
The email endpoint is designed to be called by a cron scheduler. It fetches unread Gmail messages, triages each one, and marks them as read. Create app/api/inbound/email/route.ts:
ts
import { NextResponse } from "next/server";import { TriageEngine } from "@/src/services/triage-engine.js";import { fetchUnreadMessages, markMessageAsRead } from "@/src/services/gmail-client.js";import { postMessage } from "@/src/services/slack-client.js";import { config } from "@/src/lib/config.js";import { GmailError } from "@/src/lib/types.js";export async function POST(): Promise<NextResponse> { try { const messages = await fetchUnreadMessages(); const results = await new TriageEngine().processBatch(messages); let p0Count = 0; let p1Count = 0; let p2Count = 0; for (const result of results) { if (result.priority === "P0") p0Count++; else if (result.priority === "P1") p1Count++; else p2Count++; } const p0Results = results.filter((r) => r.priority === "P0"); if (p0Results.length > 0 && config.slack.p0Channel) { await postMessage( config.slack.p0Channel, `P0 alert: ${String(p0Results.length)} urgent message(s) found`, ); } for (const message of messages) { await markMessageAsRead(message.id); } return NextResponse.json({ processed: results.length, p0: p0Count, p1: p1Count, p2: p2Count, }); } catch (error: unknown) { if (error instanceof GmailError) { return NextResponse.json({ error: "Gmail fetch failed" }, { status: 502 }); } return NextResponse.json({ error: "Internal error" }, { status: 500 }); }}
Step 17: Create the health check
A simple health check endpoint. Create app/api/health/route.ts:
ts
import { NextResponse } from "next/server";export function GET(): NextResponse { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), uptime: process.uptime(), });}
Step 18: Set up Next.js instrumentation
The instrumentation hook starts the circuit breaker persistence layer when Next.js runs in Node.js mode. First, update next.config.ts to enable the hook:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, },};export default nextConfig;
Then create src/instrumentation.ts:
ts
export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { startCircuitBreakerPersistence } = await import("@reaatech/agent-mesh-utils"); await startCircuitBreakerPersistence(); }}
The dynamic import() inside register() is required because register() runs in both Node and Edge runtimes — the Edge runtime would fail at module-load time on Node-only dependencies.
Step 19: Create the MSW test mocks
All tests use MSW (Mock Service Worker) for HTTP-level mocking. Create three handler files in tests/mocks/.
Each test file instantiates its own setupServer(...handlers) from MSW (not a shared setup file), ensuring they’re self-contained and don’t interfere with each other.
Step 21: Create the .env.example
Create .env.example with all the environment variables the application reads:
env
# SlackSLACK_BOT_TOKEN=<your-slack-bot-token>SLACK_SIGNING_SECRET=<your-signing-secret># Gmail (service account JWT auth)GMAIL_CLIENT_EMAIL=<service-account@project.iam.gserviceaccount.com>GMAIL_PRIVATE_KEY="[REDACTED PRIVATE KEY]\n"GMAIL_USER_EMAIL=<inbox-to-monitor@gmail.com># LLM provider (agnostic — swap OPENAI_BASE_URL for any OpenAI-compatible provider)OPENAI_API_KEY=<***>OPENAI_BASE_URL=https://api.openai.com/v1LLM_MODEL=gpt-5.2# BudgetBUDGET_LIMIT_USD=10.0# Slack channels for alerts (optional — falls back to source channel)P0_CHANNEL_ID=P1_CHANNEL_ID=# Triage pollingTRIAGE_POLL_INTERVAL_MS=60000# Langfuse observability (optional — omit to disable tracing)LANGFUSE_PUBLIC_KEY=LANGFUSE_SECRET_KEY=LANGFUSE_BASE_URL=# REAA agent-mesh packages (require GCP project placeholder even for mock mode)GOOGLE_CLOUD_PROJECT=local-devAPI_KEY=local-dev-key
Step 22: Run the full test suite
With all files in place, run the complete test suite:
terminal
pnpm typecheckpnpm lintpnpm exec vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output:
pnpm typecheck exits 0 (no TypeScript errors)
pnpm lint exits 0 (no ESLint violations)
pnpm vitest run shows all tests passing, 0 failing
Connect real Slack and Gmail — create a Slack app with the chat:write and channels:history scopes, and set up a GCP service account with Gmail API access. Fill in .env and start the dev server with pnpm dev.
Add a Slack Events API subscription — point your Slack app’s Request URL to https://your-domain.com/api/inbound/slack and enable the message.channels event subscription.
Schedule the email poller — set up a cron job (e.g., Vercel Cron Jobs, GitHub Actions scheduled workflow) to hit POST /api/inbound/email every 60 seconds.
Extend the agent registry — add more specialist agents for different domains (billing, technical support, account management) with their own confidence thresholds and example phrases.
Add Langfuse tracing — set LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and LANGFUSE_BASE_URL to see every triage trace visualized with input, output, and latency breakdowns.
as
const
,
is_default: false,
confidence_threshold: 0.7,
clarification_required: false,
examples: ["server is down", "security breach", "outage"],