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 multi-agent triage pipeline that automatically classifies and routes incoming Linear issues to specialized AI agents. When a new issue is created in Linear, a webhook fires and a Temporal workflow kicks off: an orchestrator agent classifies the issue via Azure OpenAI and a confidence router, a handoff protocol transfers context to the right specialist (debug, docs, scheduling, or generic), circuit breakers protect against API failures, a budget guard enforces cost limits, and every step is traced to Langfuse via OpenTelemetry. By the time an engineer opens the issue, it already has priority, labels, and a triage comment.
Prerequisites
Node.js 22+ and pnpm 10+
Azure OpenAI deployment (GPT-4o or comparable) with endpoint, API key, and two deployment names (orchestrator and specialist)
Linear account with an API key and a webhook signing secret
Langfuse instance (cloud or self-hosted) with public and secret keys
Temporal server (optional — the pipeline falls back to direct execution if Temporal is unreachable)
Step 1: Environment variables
Create a .env file (copy from .env.example) and fill in all values. The config module validates these at startup and fails fast if any required variable is missing.
terminal
# Env vars used by azure-ai-multi-agent-handoff-for-linear-issue-triage.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Azure OpenAIAZURE_OPENAI_ENDPOINT=<your-azure-openai-endpoint>AZURE_OPENAI_API_KEY=<your-azure-openai-key>AZURE_OPENAI_DEPLOYMENT_ORCHESTRATOR=<your-orchestrator-model-deployment>AZURE_OPENAI_DEPLOYMENT_SPECIALIST=<your-specialist-model-deployment># LinearLINEAR_API_KEY=<your-linear-api-key>LINEAR_WEBHOOK_SIGNING_SECRET=<your-linear-signing-secret># LangfuseLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=<your-langfuse-host># TemporalTEMPORAL_HOST=<your-temporal-server-host>TEMPORAL_NAMESPACE=<your-temporal-namespace>TEMPORAL_TASK_QUEUE=linear-triage
The config module (src/config.ts) uses zod to parse and validate these at runtime. The getEnv() function caches the parsed result so validation only runs once:
Expected output: Running pnpm typecheck after this file is in place produces zero errors.
Step 3: Build the Azure OpenAI client adapter
The src/lib/azure-openai.ts module wraps the @azure/openai SDK. It provides two helpers: one for raw chat completions and one for structured JSON output with schema validation. The validateSchema function checks the output against the provided schema when it is non-empty, throwing if any key has the wrong type.
ts
import { AzureOpenAI } from "@azure/openai";import { getEnv } from "../config.js";export function createAzureOpenAIClient(): AzureOpenAI { const env = getEnv(); return new AzureOpenAI({ endpoint: env.AZURE_OPENAI_ENDPOINT, apiKey: env.AZURE_OPENAI_API_KEY, });}export function getOrchestratorModel(): string { return getEnv().AZURE_OPENAI_DEPLOYMENT_ORCHESTRATOR;}export function getSpecialistModel(): string { return getEnv().AZURE_OPENAI_DEPLOYMENT_SPECIALIST;
Expected output:generateStructuredOutput returns an object with data, inputTokens, and outputTokens fields. If the schema is empty {}, validation is skipped; otherwise the output is checked against the schema.
Step 4: Build the Linear SDK adapter
src/lib/linear-client.ts wraps @linear/sdk. It exposes typed helpers for reading and writing Linear issues, plus a webhook signature verifier using HMAC-SHA256 with constant-time comparison.
ts
import { createHmac, timingSafeEqual } from "crypto";import { LinearClient } from "@linear/sdk";import { getEnv } from "../config.js";export function createLinearClient(): LinearClient { return new LinearClient({ apiKey: getEnv().LINEAR_API_KEY });}export async function getIssue( client: LinearClient, issueId: string,) { return client.issue(issueId);}export async function updateIssueLabels( client: LinearClient, issueId: string, labelIds: string[],) { return client.updateIssue(issueId, { labelIds });}export async function updateIssueAssignee( client: LinearClient, issueId: string, assigneeId: string,) { return client.updateIssue(issueId, { assigneeId });}export async function addComment( client: LinearClient, issueId: string, body: string,) { return client.createComment({ issueId, body });}export async function setIssuePriority( client: LinearClient, issueId: string, priority: number,) { return client.updateIssue(issueId, { priority });}export function getTeamMembers( client: LinearClient, teamId: string,) { return client.team(teamId).then((team) => team.members());}export function verifyWebhookSignature( payload: string, signature: string, secret: string,): boolean { const hmac = createHmac("sha256", secret).update(payload).digest(); const sigBytes = Buffer.from(signature, "hex"); if (hmac.length !== sigBytes.length) { return false; } return timingSafeEqual( new Uint8Array(hmac), new Uint8Array(sigBytes), );}
Expected output:verifyWebhookSignature returns true for a valid HMAC-SHA256 signature and false for an invalid one.
Step 5: Wire up Langfuse with OpenTelemetry
src/lib/langfuse-telemetry.ts initializes Langfuse and configures an OTLP trace exporter that ships spans to {LANGFUSE_HOST}/api/public/otel/v1/traces. It also provides helpers for creating and ending traces and spans, plus a withTracing wrapper that creates a span around any async function.
ts
import { Langfuse } from "langfuse";import { getEnv } from "../config.js";import { trace, SpanKind } from "@opentelemetry/api";import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";let _langfuse: Langfuse | undefined;let _otelInitialized = false;function initOpenTelemetry(): void { if (_otelInitialized) return; const env = getEnv(); const exporter = new OTLPTraceExporter({ url: `${env.LANGFUSE_HOST}/api/public/otel/v1/traces`, }); const provider = new BasicTracerProvider({ spanProcessors: [new BatchSpanProcessor(exporter)], }); trace.setGlobalTracerProvider(provider); _otelInitialized = true;}export function initLangfuse(): Langfuse { if (!_langfuse) { initOpenTelemetry(); const env = getEnv(); _langfuse = new Langfuse({ publicKey: env.LANGFUSE_PUBLIC_KEY, secretKey: env.LANGFUSE_SECRET_KEY, baseUrl: env.LANGFUSE_HOST, }); } return _langfuse;}export function createTrace( langfuse: Langfuse, name: string, input?: Record<string, unknown>,) { return langfuse.trace({ name, input });}export function createSpan( traceInstance: ReturnType<Langfuse["trace"]>, name: string, input?: Record<string, unknown>,) { return traceInstance.span({ name, input });}export function endSpan( span: ReturnType<ReturnType<Langfuse["trace"]>["span"]> | null | undefined, output: Record<string, unknown>,) { if (span) { span.end({ output }); }}export function endTrace(trace: ReturnType<Langfuse["trace"]>): void { trace.update({}); if (_langfuse) { _langfuse.flush(); }}export async function withTracing<T>( name: string, fn: () => Promise<T>,): Promise<T> { const tracer = trace.getTracer("linear-triage"); const span = tracer.startSpan(name, { kind: SpanKind.INTERNAL }); try { const result = await fn(); span.end(); return result; } catch (error) { span.recordException(error as Error); span.end(); throw error; }}
Expected output: Calling initLangfuse() creates a Langfuse client and registers the OTLP exporter. Calls to createTrace and createSpan return Langfuse trace and span objects.
Step 6: Create the confidence router service
src/services/triage-classifier.ts wraps @reaatech/confidence-router. The ConfidenceRouter.decide() method evaluates predictions against thresholds and returns a RoutingDecision with a type of ROUTE, CLARIFY, or FALLBACK. The mapClassificationToSpecialist helper maps the decision target to one of the four specialist agent IDs.
ts
import { ConfidenceRouter } from "@reaatech/confidence-router";import type { RoutingDecision } from "@reaatech/confidence-router";import type { SpecialistAgentId } from "../types.js";export interface ClassificationRoutingResult { specialistId: SpecialistAgentId; confidence: number; decision: RoutingDecision; requiresHandoff: boolean;}export function createConfidenceRouter(): ConfidenceRouter { return new ConfidenceRouter({ routeThreshold: 0.8, fallbackThreshold: 0.3, clarificationEnabled: false, });}export function classifyIssue( router: ConfidenceRouter, predictions: Array<{ label: string; confidence: number }>,): RoutingDecision { return router.decide({ predictions });}export function mapClassificationToSpecialist( decision: RoutingDecision,): SpecialistAgentId { if (decision.type === "FALLBACK" || decision.type === "CLARIFY") { return "generic-agent"; } switch (decision.target) { case "bug": case "urgent_bug": return "debug-agent"; case "docs_issue": return "docs-agent"; case "feature_request": case "question": return "generic-agent"; default: return "generic-agent"; }}
Expected output: A prediction like { label: "bug", confidence: 0.92 } returns a RoutingDecision with type: "ROUTE" and target: "bug", which maps to "debug-agent".
Step 7: Build the agent handoff orchestrator
src/services/handoff-orchestrator.ts wires together @reaatech/agent-handoff-protocol, @reaatech/agent-handoff-routing, and @reaatech/agent-handoff-compression. It creates a HandoffManager with a CapabilityBasedRouter, a HybridCompressor using a SimpleTokenCounter, and an empty TransportFactory (in-process handoffs use direct invocation). Four specialist agents are registered, and lifecycle events (handoffStart, handoffComplete, handoffReject, handoffError) log structured spans to Langfuse.
ts
import { HandoffManager, createHandoffConfig, HybridCompressor, CapabilityBasedRouter, TransportFactory, MCPTransport,} from "@reaatech/agent-handoff-protocol";import { AgentRegistry } from "@reaatech/agent-handoff-routing";import { SimpleTokenCounter } from "@reaatech/agent-handoff-compression";import { initLangfuse, createTrace, createSpan, endSpan } from "../lib/langfuse-telemetry.js";void MCPTransport;export interface HandoffResult { success: boolean; receivingAgent: { agentName: string } | undefined; duration: number
Expected output:executeHandoff with a valid target agent returns { success: true, receivingAgent: { agentName: "..." } }. Lifecycle events fire on handoffComplete and handoffError.
Step 8: Implement specialist agents
src/services/specialist-agents.ts defines a SpecialistAgent class with an analyze method that calls Azure OpenAI with a role-specific system prompt and an applyToLinear method that writes the analysis results back to Linear. The factory creates one agent per specialist ID.
ts
import type { LinearClient } from "@linear/sdk";import type { Langfuse } from "langfuse";import type { TriageInput, SpecialistAgentId } from "../types.js";import { generateStructuredOutput, getSpecialistModel, type StructuredOutputResult,} from "../lib/azure-openai.js";import { updateIssueLabels, setIssuePriority, addComment, updateIssueAssignee,} from "../lib/linear-client.js";export interface SpecialistAnalysis { category: string; suggestedLabels: string[];
Expected output:createSpecialistFactory returns a Map with four keys (debug-agent, docs-agent, scheduling-agent, generic-agent). Each agent’s analyze method returns a SpecialistAnalysisResult with token counts from the LLM call.
Step 9: Add circuit breakers
src/services/circuit-guard.ts wraps calls to the Linear API and Azure OpenAI with circuit breakers from @reaatech/circuit-breaker-agents. The Linear breaker opens after 5 consecutive failures and recovers after 30 seconds. The LLM breaker opens after 3 failures and recovers after 60 seconds.
Expected output: When the circuit is open, guardedLLMCall returns { error: "circuit_open", name: "azure-openai" } instead of propagating a CircuitOpenError. When the circuit is closed, the wrapped function’s result passes through normally.
Step 10: Add budget enforcement
src/services/cost-guard.ts wires @reaatech/agent-budget-middleware, @reaatech/agent-budget-engine, and @reaatech/agent-budget-spend-tracker. It defines a budget with a limit of 5.0 with a soft cap at 80% and a hard cap at 100%. The checkBudget function calls interceptor.beforeStep and the recordSpend function calls interceptor.afterStep after each LLM call.
Expected output: When budget is below 80%, checkBudget returns allowed: true with an optional warning string. When budget is exhausted, it throws a BudgetExceededError with a remaining property.
Step 11: Build the orchestrator service
src/services/orchestrator-agent.ts orchestrates the classification pipeline. It calls generateStructuredOutput with the orchestrator system prompt to classify the issue, feeds the result into the confidence router, maps the decision to a specialist agent, and generates a human-readable summary. All steps are wrapped in Langfuse spans.
ts
import type { Langfuse } from "langfuse";import { ConfidenceRouter } from "@reaatech/confidence-router";import { generateStructuredOutput, generateCompletion, getOrchestratorModel, type AzureLLMResponse, type StructuredOutputResult,} from "../lib/azure-openai.js";import { classifyIssue, mapClassificationToSpecialist,} from "../services/triage-classifier.js";import type { ClassificationRoutingResult } from "../services/triage-classifier.js";import type { TriageInput, ClassificationResult } from "../types.js";import { createSpan, endSpan } from "../lib/langfuse-telemetry.js";
Expected output:runOrchestrator returns an OrchestratorOutput with classification (containing specialistId, confidence, decision, and requiresHandoff), summary (non-empty string), and token counts for both LLM calls.
Step 12: Build the triage workflow
src/workflows/triage.ts is the heart of the pipeline. It initializes all services, checks budget, runs the orchestrator, executes the handoff if needed, runs the specialist analysis, applies results to Linear, records spend, and handles timeouts and errors. When Temporal is available, it starts a workflow there; otherwise it falls back to direct async execution.
ts
import { Client, Connection } from "@temporalio/client";import { createAzureOpenAIClient } from "../lib/azure-openai.js";import { createLinearClient, updateIssueLabels, addComment,} from "../lib/linear-client.js";import { initLangfuse, createTrace, endTrace,} from "../lib/langfuse-telemetry.js";import { createConfidenceRouter } from "../services/triage-classifier.js";import { createHandoffManager, executeHandoff,} from "../services/handoff-orchestrator.js";import { createSpecialistFactory } from "../services/specialist-agents.js";
Expected output: When the workflow runs to completion, it returns WorkflowOutput with status: "routed", specialistId, and triageDurationMs. On a budget check failure, it returns status: "budget_exhausted" without calling Azure OpenAI or Linear. On a timeout (60 seconds), the Linear issue receives the "triage_timeout" label.
Step 13: Add the Linear webhook route
app/api/linear/webhook/route.ts receives issue creation webhooks from Linear, validates the HMAC-SHA256 signature, and triggers the triage workflow. It only processes events where type === "Issue" and action === "create"; all other events are skipped.
Expected output: A POST request with a valid webhook payload and correct signature returns { status: "accepted", workflowId: "..." } with HTTP 202. A GET request returns { status: "ok", service: "linear-triage-webhook" } with HTTP 200.
Step 14: Configure Next.js instrumentation
src/instrumentation.ts runs at Next.js startup (enabled by experimental.instrumentationHook: true in next.config.ts). The register function checks for NEXT_RUNTIME === "nodejs" and then dynamically imports and calls validateConfig() and initLangfuse(). Dynamic imports are required because config.js and langfuse-telemetry.ts pull in Node-only modules.
Expected output: When the Next.js server starts, the console shows [instrumentation] Langfuse OTLP tracing enabled. If any required env var is missing, the process exits at startup with a descriptive error.
Step 15: Run the tests
The test suite covers every module. Run it with coverage:
terminal
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Key test coverage targets:
tests/types.test.ts — validates TriageInput, TriageResult, AgentCapability, and SpecialistAgentId union type
tests/config.test.ts — validates validateConfig() with all required vars and fallback defaults
tests/lib/azure-openai.test.ts — mocks AzureOpenAI.chat.completions.create for success, 429, and 401 paths
tests/lib/linear-client.test.ts — mocks LinearClient for label updates, comments, and webhook signature verification
tests/lib/langfuse-telemetry.test.ts — mocks Langfuse for trace creation and span lifecycle
tests/services/triage-classifier.test.ts — mocks ConfidenceRouter.decide for ROUTE, CLARIFY, and FALLBACK paths
tests/services/handoff-orchestrator.test.ts — mocks HandoffManager.executeHandoff for success, rejection, and error events
tests/services/specialist-agents.test.ts — mocks generateStructuredOutput for each specialist role
tests/services/circuit-guard.test.ts — mocks CircuitBreaker.execute for closed-circuit pass-through and circuit-open errors
tests/services/cost-guard.test.ts — mocks BudgetInterceptor.beforeStep and afterStep for allowed, warning, and exceeded paths
tests/services/orchestrator-agent.test.ts — mocks both LLM calls and verifies span creation
tests/workflows/triage.test.ts — full integration mock with all externals, budget-exhausted early return, circuit-open paths, and error recovery
tests/api/linear-webhook.test.ts — tests signature validation, payload extraction, and workflow start
tests/instrumentation.test.ts — tests register() for nodejs vs edge runtime paths
Expected output: All tests pass with zero failures. The coverage report shows lines, branches, functions, and statements all above 90% on src/**/*.ts and app/**/route.ts.
Next steps
Connect a Temporal cluster for durable workflow execution — the pipeline falls back to direct async calls when Temporal is unavailable, but a Temporal deployment gives you workflow replay, timeouts, and activity retries out of the box.
Add a dashboard page at app/page.tsx that displays recent triage outcomes by querying Langfuse for traces matching the linear-triage name prefix.
Expand the specialist agents with additional roles (security-agent, performance-agent) and wire them into the handoff manager’s agent registry.
Configure the Langfuse dashboard to track cost per issue, triage latency, and handoff success rate as experiment-level metrics.
Set up a GitHub Action that triggers the webhook endpoint when new issues are created in a test Linear workspace, running the full pipeline end-to-end on every issue event.
const agents = new Map<SpecialistAgentId, SpecialistAgent>();
agents.set(
"debug-agent",
new SpecialistAgent({
agentId: "debug-agent",
azureClient: deps.azureClient,
linearClient: deps.linearClient,
trace: deps.trace,
deployment,
systemPrompt:
"You are a debug specialist. Analyze bug reports and provide: error reproduction steps, stack trace analysis, affected components, severity assessment. Output JSON.",
}),
);
agents.set(
"docs-agent",
new SpecialistAgent({
agentId: "docs-agent",
azureClient: deps.azureClient,
linearClient: deps.linearClient,
trace: deps.trace,
deployment,
systemPrompt:
"You are a documentation specialist. Analyze documentation requests and provide: doc gaps identified, API surface completeness assessment, readability recommendations, suggested help content. Output JSON.",
}),
);
agents.set(
"scheduling-agent",
new SpecialistAgent({
agentId: "scheduling-agent",
azureClient: deps.azureClient,
linearClient: deps.linearClient,
trace: deps.trace,
deployment,
systemPrompt:
"You are a scheduling specialist. Analyze feature requests and provide: sprint impact, dependency analysis, effort estimation, milestone alignment. Output JSON.",
}),
);
agents.set(
"generic-agent",
new SpecialistAgent({
agentId: "generic-agent",
azureClient: deps.azureClient,
linearClient: deps.linearClient,
trace: deps.trace,
deployment,
systemPrompt:
"You are a general triage specialist. Analyze incoming issues and provide: categorization, suggested labels, priority assignment, routing guidance. Output JSON.",
}),
);
return agents;
}
const orchSystemPrompt =
"You are an issue triage orchestrator. Analyze the title and description of a Linear issue and classify it into exactly one of these categories: bug, feature_request, docs_issue, question, urgent_bug. Return your classification as a JSON array of predictions with confidence scores (0-1) that sum to 1. Also consider: is this a critical production issue? Does it mention errors, crashes, or data loss?";
"You are a triage summary generator. Based on the issue details and classification, produce a concise human-readable summary of the triage decision including the classification category, confidence level, and recommended actions.";
function truncateDescription(description: string, maxLength: number): string {