Support teams in Slack are flooded with requests, and manual triaging to the right expert delays responses, hurting customer satisfaction and agent productivity.
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 recipe builds a Slack support triage system that automatically routes incoming messages to specialist AI agents — billing, technical, or account management — using AWS Bedrock LLMs and a keyword-based confidence router. You wire together @reaatech packages for routing and handoff, a Bedrock LLM client for responses, and a Slack Bolt app for message handling. By the end, your Slack workspace will classify and respond to support requests without a human in the loop.
Prerequisites
Node.js 22 or later
AWS account with Bedrock access (model: anthropic.claude-sonnet-4-v1:0)
Slack app with Bot Token (xoxb-...) and Signing Secret
OpenAI API key (used for agent memory embeddings)
Langfuse account for LLM observability (optional, can be stubbed)
Step 1: Explore the project structure
The artifact materializes a full TypeScript project. Here is what was built under src/:
The src/lib/config.ts module reads these and returns typed config objects:
ts
export function getSlackConfig(): { botToken: string; signingSecret: string } { const botToken = process.env["SLACK_BOT_TOKEN"]; const signingSecret = process.env["SLACK_SIGNING_SECRET"]; if (!botToken) throw new Error("SLACK_BOT_TOKEN is required"); if (!signingSecret) throw new Error("SLACK_SIGNING_SECRET is required"); return { botToken, signingSecret };}export function getBedrockConfig(): { region: string; credentials?: { accessKeyId: string; secretAccessKey: string };} { const region = process.env["AWS_REGION"] ?? "us-east-1"; const accessKeyId = process.env["AWS_ACCESS_KEY_ID"]; const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"]; const credentials = accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined; return { region, credentials };}export function getLangfuseConfig(): { publicKey: string; secretKey: string; host: string } { return { publicKey: process.env["LANGFUSE_PUBLIC_KEY"] ?? "", secretKey: process.env["LANGFUSE_SECRET_KEY"] ?? "", host: process.env["LANGFUSE_HOST"] ?? "https://cloud.langfuse.com", };}export function getServerPort(): number { return Number(process.env["PORT"] ?? 3000);}export function getBedrockModelId(): string { return process.env["BEDROCK_MODEL_ID"] ?? "anthropic.claude-sonnet-4-v1:0";}
Expected output: Each getter either returns a valid config or throws with a descriptive message. getServerPort() defaults to 3000 when PORT is unset. getBedrockModelId() falls back to anthropic.claude-sonnet-4-v1:0.
Step 4: Define shared types
The src/lib/types.ts file re-exports the protocol schemas from @reaatech/agent-mesh so the rest of the codebase imports from one place. It also adds recipe-specific interfaces:
The TriageResult discriminated union makes each branch type-safe: a "routed" result always has agentId and response, a "clarification" result always has question and candidates, and a "fallback" result always has reason.
Step 5: Build the Bedrock LLM client
The src/services/llm/bedrock-client.ts wraps the AWS SDK’s ConverseCommand with a simple typed interface:
Expected output:converse returns { text: "LLM response text", inputTokens: N, outputTokens: M }. If Bedrock returns an empty content array, it throws "Bedrock returned empty response". The systemPrompt option injects a system-level prompt that sets the model persona (billing specialist, technical support, etc.).
Step 6: Create the intent classifier
The src/services/classifier.ts sets up a ConfidenceRouter with a KeywordClassifier for each specialist domain. This is the first routing layer — it uses keyword matching to decide which agent category a message belongs to:
Expected output:"I have a question about my invoice" triggers a ROUTE decision with target: "billing-agent". "the API is returning 500 errors" routes to technical-agent. "I can't log into my account" routes to account-agent. A message with no matching keywords returns { action: "fallback" }.
Step 7: Define specialist agent capabilities
The src/lib/agent-capabilities.ts declares the capability descriptors for each specialist. These descriptors feed into the AgentRegistry and CapabilityBasedRouter:
Each capability object describes the agent’s skills, domains, and load characteristics. The CapabilityBasedRouter uses these to score and rank agents for a given handoff payload.
Step 8: Implement specialist agent handlers
The src/services/agents/specialists.ts creates handler functions for each specialist. Each handler retrieves relevant memories from the agent memory store, then calls the Bedrock LLM with a domain-specific system prompt:
ts
import type { AgentMemory } from "@reaatech/agent-memory";import type { ContextPacket, AgentResponse } from "@reaatech/agent-mesh";import { AgentResponseSchema } from "@reaatech/agent-mesh";import { BedrockLLMClient } from "../llm/bedrock-client.js";import { getBedrockModelId } from "../../lib/config.js";const BILLING_SYSTEM_PROMPT = "You are a billing support specialist. Handle inquiries about invoices, " + "subscriptions, payments, refunds, and pricing. Provide clear, actionable answers.";const TECHNICAL_SYSTEM_PROMPT = "You are a technical support specialist. Help users debug issues, " + "troubleshoot errors, understand APIs, and resolve integration problems.";const ACCOUNT_SYSTEM_PROMPT = "You are an account management specialist. Assist with account setup, " + "onboarding, permissions, profile changes, and access management.";function createAgentHandler( bedrock: BedrockLLMClient, memory: AgentMemory, systemPrompt: string,): (ctx: ContextPacket) => Promise<AgentResponse> { return async (ctx: ContextPacket) => { const memories = await memory.retrieve(ctx.raw_input, { limit: 5 }); const contextPreamble = memories.length > 0 ? `Relevant context from previous conversations:\n${memories.map((m) => m.content).join("\n")}\n\nCurrent request: ${ctx.raw_input}` : ctx.raw_input; const result = await bedrock.converse( getBedrockModelId(), [ { role: "user", content: [{ text: contextPreamble }], }, ], { systemPrompt: systemPrompt }, ); return AgentResponseSchema.parse({ content: result.text, workflow_complete: true, }); };}export function createBillingAgent( bedrock: BedrockLLMClient, memory: AgentMemory,): (ctx: ContextPacket) => Promise<AgentResponse> { return createAgentHandler(bedrock, memory, BILLING_SYSTEM_PROMPT);}export function createTechnicalAgent( bedrock: BedrockLLMClient, memory: AgentMemory,): (ctx: ContextPacket) => Promise<AgentResponse> { return createAgentHandler(bedrock, memory, TECHNICAL_SYSTEM_PROMPT);}export function createAccountAgent( bedrock: BedrockLLMClient, memory: AgentMemory,): (ctx: ContextPacket) => Promise<AgentResponse> { return createAgentHandler(bedrock, memory, ACCOUNT_SYSTEM_PROMPT);}export function getAgentForId( agentId: string, bedrock: BedrockLLMClient, memory: AgentMemory,): ((ctx: ContextPacket) => Promise<AgentResponse>) | undefined { switch (agentId) { case "billing-agent": return createBillingAgent(bedrock, memory); case "technical-agent": return createTechnicalAgent(bedrock, memory); case "account-agent": return createAccountAgent(bedrock, memory); default: return undefined; }}
Expected output: Each handler returns an AgentResponse shaped per AgentResponseSchema: { content: "LLM answer", workflow_complete: true }. The handler injects relevant conversation memories into the prompt context so the LLM has continuity across turns.
Step 9: Wire up agent routing
The src/services/routing.ts builds an AgentRegistry and a CapabilityBasedRouter from @reaatech/agent-handoff-routing. The registry holds all registered agents; the router scores them by skill match (40%), domain match (30%), load factor (20%), and language match (10%):
ts
import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import type { HandoffPayload } from "@reaatech/agent-handoff";import { ALL_SPECIALIST_CAPABILITIES } from "../lib/agent-capabilities.js";export function createAgentRegistry(): AgentRegistry { const registry = new AgentRegistry(); for (const cap of ALL_SPECIALIST_CAPABILITIES) { registry.register(cap); } return registry;}export function createHandoffRouter(): CapabilityBasedRouter { return new CapabilityBasedRouter({ minConfidenceThreshold: 0.5, ambiguityThreshold: 0.15, maxAlternatives: 3, policy: "best_effort", });}export async function selectTargetAgent( router: CapabilityBasedRouter, registry: AgentRegistry, payload: HandoffPayload,): Promise< | { outcome: "primary"; agentId: string } | { outcome: "clarification"; question: string; candidates: string[] } | { outcome: "fallback"; reason: string }> { const agents = registry.getAll(); const decision = await router.route(payload, agents); if (decision.type === "primary") { return { outcome: "primary", agentId: decision.targetAgent.agentId }; } if (decision.type === "clarification") { return { outcome: "clarification", question: "Could you clarify which area your request relates to?", candidates: decision.candidateAgents.map((a) => a.agentId), }; } return { outcome: "fallback", reason: "No suitable agent found" };}
Expected output: The AgentRegistry holds all three specialist capabilities. The router scores agents and returns a decision. If the top agent scores significantly above the runner-up (no ambiguity), it returns a primary outcome. If two agents score within 0.15 of each other, it returns clarification with both candidates. If no agents are available, it returns fallback.
Step 10: Build the triage orchestration service
The src/services/triage.ts is the core orchestration layer. It classifies incoming messages, resolves the correct agent handler, invokes it with retry logic via withRetry, and stores conversation turns in agent memory:
ts
import type { AgentMemory } from "@reaatech/agent-memory";import { ConfidenceRouter } from "@reaatech/confidence-router";import { ContextPacketSchema } from "@reaatech/agent-mesh";import { withRetry } from "@reaatech/agent-handoff";import { BedrockLLMClient } from "./llm/bedrock-client.js";import { createTriageClassifier, classifyMessage } from "./classifier.js";import { createAgentRegistry, createHandoffRouter } from "./routing.js";import { getAgentForId } from "./agents/specialists.js";import type { SlackMessageContext, TriageResult } from "../lib/types.js";import { ALL_SPECIALIST_CAPABILITIES } from "../lib/agent-capabilities.js";export class TriageService { private classifier: ConfidenceRouter; private bedrock: BedrockLLMClient; private memory: AgentMemory; private agentRegistry = createAgentRegistry(); private handoffRouter = createHandoffRouter(); constructor(bedrock: BedrockLLMClient, memory: AgentMemory) { this.bedrock = bedrock; this.memory = memory; this.classifier = createTriageClassifier(); } async triage(slackMsg: SlackMessageContext): Promise<TriageResult> { const classification = await classifyMessage(this.classifier, slackMsg.text); if (classification.action === "fallback") { return { type: "fallback", reason: "Could not classify the request" }; } if (classification.action === "clarify") { return { type: "clarification", question: "Could you clarify which area your request relates to?", candidates: ALL_SPECIALIST_CAPABILITIES.map((c) => c.agentId), }; } const agentId = classification.agentId; if (!agentId) { return { type: "fallback", reason: "No agent identified" }; } const agentHandler = getAgentForId(agentId, this.bedrock, this.memory); if (!agentHandler) { return { type: "fallback", reason: `No handler for agent: ${agentId}` }; } const ctxPacket = ContextPacketSchema.parse({ session_id: slackMsg.thread_ts ?? `sess-${slackMsg.ts}`, request_id: `req-${slackMsg.ts}`, employee_id: slackMsg.user, raw_input: slackMsg.text, intent_summary: `routed to ${agentId}`, entities: {}, turn_history: [], workflow_state: {}, }); try { const response = await withRetry(() => agentHandler(ctxPacket), { maxRetries: 3, backoff: "exponential", baseDelayMs: 100, maxDelayMs: 5000, shouldRetry: (err) => err instanceof Error, }); const now = new Date(); await this.memory.extractAndStore([ { speaker: "user", content: slackMsg.text, timestamp: now }, { speaker: "agent", content: response.content, timestamp: now }, ]); return { type: "routed", agentId, response }; } catch { return { type: "fallback", reason: "Agent invocation failed" }; } }}
Expected output: Calling triage(slackMsg) with "I was charged twice for my subscription" returns { type: "routed", agentId: "billing-agent", response: AgentResponse }. The AgentResponse.content text is posted back to the Slack thread. After each triage, two conversation turns are extracted and stored in agent memory for future context.
Step 11: Bootstrap the Slack Bolt app
The src/app.ts creates the Slack App with an HTTPReceiver backed by Express, wires up the message handler, and initializes Langfuse tracing:
ts
import { App, HTTPReceiver } from "@slack/bolt";import { AgentMemory, MemoryType, OpenAILLMProvider } from "@reaatech/agent-memory";import { TriageService } from "./services/triage.js";import { BedrockLLMClient } from "./services/llm/bedrock-client.js";import { getSlackConfig, getBedrockConfig, getLangfuseConfig,} from "./lib/config.js";import { initTracing, shutdownTracing } from "./services/observability/langfuse-tracer.js";import type { SlackMessageContext } from "./lib/types.js";function createMemory(): AgentMemory { const openaiKey = process.env["OPENAI_API_KEY"]; if (!openaiKey) { throw new Error("OPENAI_API_KEY is required for AgentMemory"); } return new AgentMemory({ storage: { provider: "memory" }, embedding: { provider: "openai", model: "text-embedding-3-small", apiKey: openaiKey, }, extraction: { llmProvider: new OpenAILLMProvider({ apiKey: openaiKey, model: "gpt-4o-mini", }), enabledTypes: [MemoryType.FACT, MemoryType.PREFERENCE], batchSize: 10, confidenceThreshold: 0.7, }, });}export function createApp(): App { const slackConfig = getSlackConfig(); const bedrockConfig = getBedrockConfig(); const langfuseConfig = getLangfuseConfig(); const langfuse = initTracing(langfuseConfig); const bedrock = new BedrockLLMClient(bedrockConfig.region, bedrockConfig.credentials); const memory = createMemory(); const triageService = new TriageService(bedrock, memory); const receiver = new HTTPReceiver({ signingSecret: slackConfig.signingSecret, }); const app = new App({ token: slackConfig.botToken, receiver, }); app.message(async ({ message, say }) => { if (typeof message === "string" || !("text" in message)) return; const slackMsg: SlackMessageContext = { channel: "channel" in message && typeof message.channel === "string" ? message.channel : "", user: "user" in message && typeof message.user === "string" ? message.user : "", text: message.text ?? "", ts: "ts" in message && typeof message.ts === "string" ? message.ts : "", thread_ts: "thread_ts" in message && typeof message.thread_ts === "string" ? message.thread_ts : undefined, }; const result = await triageService.triage(slackMsg); if (result.type === "routed") { await say({ text: result.response.content, thread_ts: slackMsg.ts }); } else if (result.type === "clarification") { await say({ text: result.question, thread_ts: slackMsg.ts }); } else { await say({ text: "I wasn't able to process your request. A human will follow up shortly.", thread_ts: slackMsg.ts, }); } }); process.on("SIGTERM", () => { void shutdownTracing(langfuse); }); process.on("SIGINT", () => { void shutdownTracing(langfuse); }); return app;}export async function startApp(app: App, port: number): Promise<void> { await app.start(port); console.log(`Slack Bolt listening on :${String(port)}`);}
Expected output:createApp() returns a fully configured App instance. The message handler extracts channel, user, text, ts, and thread_ts from incoming Slack messages, calls the triage service, and posts the response back as a threaded reply.
Step 12: Initialize observability
The src/services/observability/langfuse-tracer.ts sets up Langfuse for LLM observability: