The studio manager spends 30 minutes every morning texting clients who no-showed or late-canceled yesterday. They have to manually check the booking system, compose a message, and decide whether to charge a fee or offer a makeup class. This inconsistent enforcement frustrates regulars and fails to recoup lost revenue. Without automation, the studio bleeds class capacity and staff morale drops.
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 an Agnostic No-Show Chase Agent — an automated system that detects clients who no-showed or late-canceled a fitness class, decides an appropriate follow-up action (reminder, reschedule offer, penalty, or escalation), and delivers a personalized SMS. The agent is provider-agnostic: you can swap the underlying LLM (OpenAI, Anthropic, Google, etc.) without changing any application code.
The stack uses Next.js 16 (App Router) for the API surface, the Vercel AI SDK for agnostic LLM access, Twilio for SMS delivery, and six @reaatech agent-building packages — agent-mesh for request/response validation, agent-handoff for event orchestration and retry, agent-memory for long-term customer memory, agent-budget-engine for cost enforcement, agent-replay-core for trace recording, and llm-router-core for auditable model routing. All domain types are defined as Zod schemas — a single source of truth for validation and TypeScript inference.
Prerequisites
Node.js 22+ and pnpm 10+ installed
A Twilio account with an SMS-capable phone number (trial accounts work)
An OpenAI API key (for agent-memory embedding and extraction; also used if you choose an OpenAI model)
(Optional) Anthropic API key if you want a different LLM provider
(Optional) Langfuse account keys for observability (the service degrades gracefully without them)
Familiarity with TypeScript, Next.js App Router, and basic Zod usage
Step 1: Scaffold the project and install dependencies
The project is a Next.js 16 App Router application. Start by installing all dependencies:
terminal
pnpm install
After install completes, verify the project layout:
Expected output: A directory tree matching the layout above. All dependencies (6 REAA packages + 5 third-party) are exact-pinned in package.json.
Step 2: Define domain types with Zod
Every domain concept is a Zod schema in src/lib/schemas.ts. This is the single source of truth — no separate types.ts file with hand-written interfaces.
Expected output: The file exports 5 schemas and 5 inferred types. The ChaseActionSchema uses z.discriminatedUnion so that TypeScript narrows the action type based on the type discriminant — when you check action.type === "apply_penalty", TypeScript knows amount and reason exist on the branch.
Step 3: Configure the agent-mesh integration
The agent-mesh package (@reaatech/agent-mesh@1.0.0) provides validated schemas for the IncomingRequest, AgentResponse, AgentConfig, and ContextPacket types. Because the package is a server-only dependency that could fail in Edge runtimes, the code uses dynamic imports via a lazy-loaded module cache. The file also exports helper functions for building agent config and context packets.
Expected output: You can call validateIncomingRequest(raw) at the boundary to parse incoming payloads, and buildAgentResponse(content, true) to construct validated agent responses. The getMeshModule() singleton ensures the import happens only once per process lifetime.
Step 4: Wire up handoff utilities
The handoff package (@reaatech/agent-handoff@0.1.0) provides a typed event emitter, exponential-backoff retry, and handoff configuration. These are used across the agent to decouple components and add resilience to SMS delivery.
Expected output:createHandoffConfig deep-merges your partial config with defaults. The TypedEventEmitter gives type-safe on/emit for lifecycle events. notifyWithRetry wraps any async function with exponential backoff — try it by passing a function that fails twice then succeeds.
Step 5: Configure the LLM router
The @reaatech/llm-router-core@1.0.0 package provides Zod-validated model definitions and routing request shapes. This makes the agent’s model selection auditable and cost-aware.
ts
import { ModelDefinitionSchema, type ModelDefinition, RoutingRequestSchema, type RoutingRequest,} from "@reaatech/llm-router-core";export function buildModelDefinition(modelId: string): ModelDefinition { return ModelDefinitionSchema.parse({ id: modelId, provider: "agnostic", costPerMillionInput: 2, costPerMillionOutput: 8, maxTokens: 16000, capabilities: ["code", "reasoning"], });}export function buildRoutingRequest( prompt: string, maxTokens: number,): RoutingRequest { return RoutingRequestSchema.parse({ prompt, maxTokens, strategy: "cost-optimized", });}
Expected output:buildModelDefinition("openai/gpt-5.2") returns a validated ModelDefinition with the agnostic provider label. buildRoutingRequest("Hello", 4096) returns a validated RoutingRequest with the cost-optimized strategy.
Step 6: Build the LLM service
The LlmService class wraps the Vercel AI SDK (ai@6.0.208) and accepts any LanguageModel instance. This is what makes the agent truly provider-agnostic — you pass in openai("gpt-5.2"), anthropic("claude-sonnet-4-6"), or any other model from the ai package. Each method wraps the generateText call in a try/catch that converts errors to the typed LlmError.
ts
import { generateText, type LanguageModel } from "ai";import { ChaseActionSchema } from "../lib/schemas.js";import type { ChaseAction } from "../lib/schemas.js";export class LlmError extends Error { constructor(message: string, public readonly cause?: unknown) { super(message); this.name = "LlmError"; }}export class LlmService { constructor(private model: LanguageModel) {} async generateChaseMessage
Expected output: Four methods, each with a specific system prompt tuned for its type of communication. Each method wraps the LLM call in a try/catch that converts any failure into a typed LlmError. Usage is always returned so the orchestrator can track cost.
Step 7: Create the Twilio notification service
The TwilioNotificationService sends SMS messages via Twilio. It catches Twilio’s RestException and converts it into a typed NotificationError, then wraps the send operation with the exponential-backoff retry from the handoff module.
ts
import twilio from "twilio";import { notifyWithRetry } from "../lib/handoff.js";export const RestException = twilio.RestException;export class NotificationError extends Error { constructor( message: string, public readonly code: number, public readonly status: string, ) { super(message); this.name = "NotificationError"; }}export class TwilioNotificationService { private client: ReturnType<typeof twilio>; private fromNumber: string; constructor( accountSid: string, authToken: string, fromNumber: string, ) { this.client = twilio(accountSid, authToken); this.fromNumber = fromNumber; } async sendSms( to: string, body: string, ): Promise<{ sid: string; status: string }> { if (!to || !body || !body.trim()) { throw new NotificationError("Empty body or phone number", 0, "validation_error"); } try { const message = await this.client.messages.create({ body, to, from: this.fromNumber, }); return { sid: message.sid, status: message.status }; } catch (error) { if (error instanceof RestException) { const re = error as Error & { code?: number; status?: number }; throw new NotificationError( `Twilio error: ${re.message}`, re.code ?? 0, re.status != null ? String(re.status) : "unknown", ); } throw error; } } async sendSmsWithRetry( to: string, body: string, maxRetries?: number, ): Promise<{ sid: string; status: string }> { return notifyWithRetry(() => this.sendSms(to, body), maxRetries); }}
Expected output:sendSms guards against empty inputs, then calls Twilio inside a try/catch that unwraps RestException into a typed NotificationError. sendSmsWithRetry delegates to the notifyWithRetry helper — if Twilio is temporarily unavailable, it retries up to 3 times with exponential backoff.
Step 8: Add customer memory
The ChaseMemoryService wraps AgentMemory from @reaatech/agent-memory@0.1.0. It uses in-memory storage with OpenAI embeddings for semantic retrieval.
Expected output: Every chase action is stored as a memory with the action type as a prefix. getCustomerHistory("c1") returns the last 10 interactions. The memory provider auto-extracts facts and preferences from the conversation text.
Step 9: Implement budget enforcement
The ChaseBudgetService wraps BudgetController from @reaatech/agent-budget-engine@0.1.1 with a SpendStore for tracking. It defines per-customer budgets, checks whether a request is allowed, records spend, and subscribes to threshold-breach events.
Expected output:defineChaseBudget("c1", 10.0) creates a $10 budget for customer c1. When spend exceeds 80% ($8), the threshold-breach handler logs a warning. checkAllowed("c1", 0.05, "gpt-5.2") returns { allowed: true, action: "Allow" } if the customer has remaining budget.
Step 10: Set up trace recording
The ChaseReplayService wraps RecordingEngine from @reaatech/agent-replay-core@0.1.0. It captures agent interactions as spans and events, producing a trace that can be replayed later for debugging without consuming LLM tokens.
Expected output:startRecording("c1", "no-show-chase") returns a session. Call recordLlmSpan between LLM calls to capture request/response pairs. stopRecording(session) returns the finalized trace.
Step 11: Add observability with Langfuse
The ObservabilityService wraps Langfuse from langfuse@3.38.20. It operates in no-op mode when Langfuse env vars are absent — the agent never crashes because of missing observability configuration.
Expected output: When LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY are set, createTrace and span return non-null trace/span objects. When they’re absent, all methods return null without throwing.
Step 12: Build the chase agent orchestrator
The ChaseAgentService wires all services together. Its processNoShow method runs the full decision pipeline: validate input, check budget, ask the LLM for an action, branch on the decision, send SMS, store to memory, and record the trace.
ts
import type { NoShowRecord, ChaseResult, ChaseConfig } from "../lib/schemas.js";import { ChaseResultSchema } from "../lib/schemas.js";import { validateIncomingRequest, buildAgentResponse } from "../lib/agent-config.js";import { LlmService } from "./llm.service.js";import { TwilioNotificationService } from "./notification.service.js";import { ChaseMemoryService } from "./memory.service.js";import { ChaseBudgetService } from "./budget.service.js";import { ChaseReplayService } from "./replay.service.js";import { ObservabilityService } from "./observability.service.js";export class ChaseAgentService { constructor(
Expected output: The pipeline is fully deterministic — for each NoShowRecord, it produces exactly one ChaseResult with the action the LLM decided, the SMS that was sent (or null for escalations), and the cost incurred. The calculateCost method computes cost from the model’s per-token pricing.
Step 13: Create the API routes
Two Next.js App Router route handlers expose the agent: POST /api/chase to trigger a chase and GET /api/chase/[id] to retrieve history.
Expected output: Both routes use NextRequest/NextResponse (never bare Request/Response). The GET handler uses the Next 15+ async params pattern with Promise. Validation errors produce 400, service errors produce 500, and a missing customer ID produces 404.
Step 14: Set environment variables and configure the agent
Copy .env.example to .env.local and fill in the real values:
Expected output: The agent reads these env vars at startup. Langfuse is optional — if LANGFUSE_SECRET_KEY is missing, the ObservabilityService silently no-ops. Change DEFAULT_MODEL and add the corresponding API key to swap providers.
Step 15: Run the tests
The project includes a vitest suite with mocks for all external services (Twilio, ai, Langfuse, agent-memory, agent-replay-core):
terminal
pnpm typecheck
Expected output: zero TypeScript errors in both source and test files.
terminal
pnpm lint
Expected output: zero lint warnings.
terminal
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: all tests pass. The test suite covers every module plus integration tests that mock the full pipeline from POST request to SMS delivery. All external calls are mocked via vi.mock — no real Twilio or LLM calls are made during tests.
Add a webhook receiver — integrate with a real booking system (e.g., Mindbody, ClassPass) to automatically receive no-show notifications rather than posting manually
Build a dashboard page — create an app/dashboard/page.tsx that displays active chases, recent escalations, and budget utilization using the GET /api/chase/:id endpoint
Switch the memory backend — change storage: { provider: "memory" } to storage: { provider: "postgres", connection: { ... } } in ChaseMemoryService for persistent storage across restarts
Add model fallback — wire the DowngradeEngine from @reaatech/agent-budget-engine to automatically switch to a cheaper model when budget thresholds are breached
Deploy to production — add Sentry for error tracking, rate-limit the POST /api/chase endpoint, and configure Langfuse for observability
const system = "You are a fitness studio manager composing personalized, empathetic follow-up messages to clients who missed class. Include the client's name, reference the missed class, and sound encouraging rather than punitive.";
const system = "Decide the appropriate follow-up action for a client who missed a fitness class. Consider their loyalty, history, and membership tier.";
const system = "You are a fitness studio manager notifying a client about a late-cancel/no-show penalty fee. Be professional, clear, and refer to the published studio policy.";