A studio manager spends hours each week manually texting members who no-show or late-cancel, trying to enforce cancellation policies. Inconsistent follow-up means many members skip fees, hurting revenue. The manager needs a reliable system that detects missed classes, applies policy, and sends reminders without manual effort.
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 builds an automated no-show and late-cancel enforcement system for fitness studios. You’ll wire 5 REAA packages (@reaatech/agent-runbook-*) into a Next.js 16 + Fastify stack, creating a document-pipeline that reads attendance records, applies cancellation policy, generates LLM-powered SMS messages, sends them via Twilio, and produces runbooks and alert definitions — all traced through Langfuse.
Prerequisites
Node.js >= 22 and pnpm 10 installed on your machine
A Twilio account with an SMS-capable phone number (trial accounts work)
An OpenAI API key (for LLM message generation)
A Langfuse account (free tier works — for LLM observability tracing)
Familiarity with TypeScript, Next.js App Router, and basic REST API concepts
You’ll create the following files as you go — the final project structure looks like this:
text
app/ api/ enforce/route.ts health/route.ts
page.tsx
layout.tsx
src/
lib/
types.ts
config.ts
llm.ts
observability.ts
services/
attendance-analyzer.ts
policy-enforcer.ts
notification-service.ts
runbook-builder.ts
alert-generator.ts
health-check-generator.ts
enforcement-pipeline.ts
api/
fastify-server.ts
index.ts
tests/
lib/
types.test.ts
config.test.ts
llm.test.ts
observability.test.ts
services/
attendance-analyzer.test.ts
policy-enforcer.test.ts
notification-service.test.ts
runbook-builder.test.ts
alert-generator.test.ts
health-check-generator.test.ts
enforcement-pipeline.test.ts
app/api/
enforce/route.test.ts
health/route.test.ts
api/
fastify-server.test.ts
index.test.ts
package.json
next.config.ts
.env.example
Step 1: Scaffold the project and install dependencies
Scaffold a Next.js project with TypeScript, then install every pinned dependency.
grep -n '[\"~^>]' package.json || echo "All versions are exact"
Expected output: No matches — every dependency is pinned to an exact version.
Step 2: Configure environment variables
Create .env from the example and fill in your credentials. The recipe reads these at module load time.
env
# Env vars used by agnostic-no-show-enforcement-agent.# Keep placeholders only — never commit real values.NODE_ENV=development# TwilioTWILIO_ACCOUNT_SID=<your-twilio-account-sid>TWILIO_AUTH_TOKEN=<your-twilio-auth-token>TWILIO_FROM_NUMBER=<your-twilio-phone-number># OpenAI (used by @ai-sdk/openai for LLM message generation)OPENAI_API_KEY=<your-openai-api-key># Langfuse LLM observabilityLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=<your-langfuse-host># Studio enforcement policySTUDIO_NAME=<your-studio-name>CANCELLATION_WINDOW_HOURS=24NO_SHOW_FEE_AMOUNT=15LATE_CANCEL_FEE_AMOUNT=10
The policy values (CANCELLATION_WINDOW_HOURS, NO_SHOW_FEE_AMOUNT, LATE_CANCEL_FEE_AMOUNT) map to the config loader in Step 4, while STUDIO_NAME is used when composing notification messages.
Step 3: Define domain types and Zod schemas
The core of the system lives in src/lib/types.ts. You’ll define interfaces for members, class sessions, attendance records, enforcement policies, and actions — plus Zod schemas for runtime validation at API boundaries.
Expected output: TypeScript compiles these without errors. Each Zod schema uses z.coerce.date() so you can pass ISO strings from JSON API bodies and they’ll be coerced to Date objects automatically.
Step 4: Build configuration from environment variables
src/lib/config.ts reads every env var, validates them with a Zod schema, and exports a PipelineConfig, an EnforcementPolicy, and the studioName. If a required variable is missing, it throws a ConfigurationError from @reaatech/agent-runbook.
Expected output: With all env vars set, pipelineConfig.twilioAccountSid returns your Twilio SID and studioName returns your studio name. If you unset TWILIO_ACCOUNT_SID, the module throws ConfigurationError on import.
Step 5: Create the LLM client
src/lib/llm.ts wraps Vercel AI SDK’s generateText with the OpenAI provider. It generates polite, firm SMS messages for members who’ve triggered enforcement actions.
ts
// src/lib/llm.tsimport { generateText } from "ai";import { openai } from "@ai-sdk/openai";export interface LlmClient { generateEnforcementMessage( memberName: string, actionType: string, reason: string, amount?: number, ): Promise<{ text: string; usage: { inputTokens: number; outputTokens: number } }>;}export function createLlmClient(apiKey: string): LlmClient { if (!apiKey) { throw new Error("OpenAI API key is required"); } return { async generateEnforcementMessage(memberName, actionType, reason, amount) { const amountClause = amount != null && amount > 0 ? ` with a fee of $${String(amount)}` : ""; const result = await generateText({ model: openai("gpt-5.2-mini"), system: "You write polite, firm SMS messages for a fitness studio. Keep messages under 320 characters.", prompt: `Write an SMS for member "${memberName}" about a "${actionType}" due to "${reason}"${amountClause}.` + " Include their name at the start. Sign with the studio name.", }); return { text: result.text, usage: { inputTokens: result.usage.inputTokens ?? 0, outputTokens: result.usage.outputTokens ?? 0, }, }; }, };}
Expected output:createLlmClient("sk-...") returns an object with generateEnforcementMessage. When called, it returns an object with { text, usage }. The text is an SMS-ready message under 320 characters.
Step 6: Set up Langfuse observability
src/lib/observability.ts creates a Langfuse client and provides helpers to trace enforcement runs, log LLM generations, and log enforcement events.
Expected output: After calling createLangfuseClient(...) and traceEnforcementRun(client, "enforcement-run"), you get a trace handle. logLlmGeneration and logEnforcementEvent attach child spans and events to that trace, visible in your Langfuse dashboard.
Step 7: Build services with REAA packages
This is the core of the recipe. You’ll build six service factories, each wrapping one or more REAA packages.
Attendance Analyzer
Uses @reaatech/agent-runbook-agent’s createAnalysisAgent to get LLM-powered failure mode identification, then maps those to EnforcementAction objects.
ts
// src/services/attendance-analyzer.tsimport { type AnalysisContext, AnalysisError } from "@reaatech/agent-runbook";import { createAnalysisAgent, type AnalysisAgent } from "@reaatech/agent-runbook-agent";import type { AttendanceRecord, EnforcementPolicy, EnforcementAction } from "../lib/types.js";import { EnforcementActionSchema } from "../lib/types.js";function makeContext(): AnalysisContext { return JSON.parse(JSON.stringify({})) as AnalysisContext;}function isNoShowOrLateCancel(status: string): boolean { return status === "no-show" || status === "late-cancel";}export function createAttendanceAnalyzer(apiKey: string) { const agent: AnalysisAgent = createAnalysisAgent({ provider: "openai", model: "gpt-5.2-mini", apiKey, }); async function analyzeRecords( records: AttendanceRecord[], policy: EnforcementPolicy, ): Promise<EnforcementAction[]> { if (records.length === 0) { return []; } const context = makeContext(); try { const failureModes = await agent.identifyFailureModes(context); const actions: EnforcementAction[] = []; const memberCounts = new Map<string, number>(); for (const record of records) { if (!isNoShowOrLateCancel(record.status)) continue; const count = memberCounts.get(record.memberId) ?? 0; memberCounts.set(record.memberId, count + 1); } for (const record of records) { if (!isNoShowOrLateCancel(record.status)) continue; const infractionCount = memberCounts.get(record.memberId) ?? 0; let actionType: EnforcementAction["actionType"]; if (infractionCount >= policy.maxWarningsBeforeSuspension) { actionType = "suspension"; } else if (infractionCount > 1) { actionType = "fee"; } else { actionType = "warning"; } const failureReason = failureModes.find(m => m.toLowerCase().includes(record.memberId.toLowerCase())) ?? `${record.status} on class ${record.classId}`; const action: EnforcementAction = { memberId: record.memberId, memberName: `Member-${record.memberId}`, memberPhone: "", actionType, amount: actionType === "fee" ? policy.noShowFee : undefined, reason: failureReason, dueDate: new Date(Date.now() + 7 * 86400000), }; const parsed = EnforcementActionSchema.safeParse(action); if (parsed.success) { actions.push(action); } } return actions; } catch (error) { throw new AnalysisError( error instanceof Error ? error.message : "Attendance analysis failed", ); } } return { analyzeRecords };}
Policy Enforcer
Applies the configured policy rules — fee amounts for no-shows vs late-cancels, warning thresholds, and suspension triggers. Pure business logic, no external calls.
ts
// src/services/policy-enforcer.tsimport { ValidationError } from "@reaatech/agent-runbook";import type { AttendanceRecord, EnforcementPolicy, EnforcementResult } from "../lib/types.js";export function createPolicyEnforcer(policy: EnforcementPolicy) { if (policy.maxWarningsBeforeSuspension < 0) { throw new ValidationError("maxWarningsBeforeSuspension must be >= 0"); } function evaluate(records: AttendanceRecord[]): EnforcementResult[] { if (records.length === 0) { return []; } const infractionCounts = new Map<string, number>(); for (const r of records) { if (r.status === "no-show" || r.status === "late-cancel") { infractionCounts.set(r.memberId, (infractionCounts.get(r.memberId) ?? 0) + 1); } } const results: EnforcementResult[] = []; for (const record of records) { if (record.status === "attended" || record.status === "on-time-cancel") continue; const count = infractionCounts.get(record.memberId) ?? 0; let actionType: EnforcementResult["action"]["actionType"]; let amount: number | undefined; if (record.status === "no-show") { amount = policy.noShowFee; } else { amount = policy.lateCancelFee; } if (count >= policy.maxWarningsBeforeSuspension) { actionType = "suspension"; amount = undefined; } else if (count > 1) { actionType = "fee"; } else { actionType = "warning"; if (record.status === "no-show") { amount = undefined; } } const hasPhone = record.memberId.length > 0; const result: EnforcementResult = { memberId: record.memberId, sessionId: record.classId, action: { memberId: record.memberId, memberName: `Member-${record.memberId}`, memberPhone: "", actionType, amount, reason: `${record.status} on ${record.scheduledDate.toISOString()}`, dueDate: new Date(Date.now() + 7 * 86400000), }, notified: hasPhone, timestamp: new Date(), }; results.push(result); } return results; } return { evaluate };}
Notification Service
Sends SMS via Twilio. Handles the RestException type for structured error reporting and guards against empty phone numbers without calling the API.
src/services/enforcement-pipeline.ts ties everything together. It creates all service instances, runs the analyzer and enforcer, generates SMS text via the LLM, sends notifications, logs everything to Langfuse, and assembles the final output.
Expected output:runEnforcementPipeline(config, records) returns { results, runbook, alertDefinitions }. With empty records, it skips LLM and Twilio calls entirely and returns empty results plus default runbook and alerts.
Step 9: Create the Fastify server
The separate Fastify server (runs on port 3001) exposes four endpoints — health, enforcement, runbook, and alerts.
Expected output: Starting the Fastify server gives you GET /health returning { status: "ok", timestamp: "..." }, POST /enforce processing attendance records, GET /runbook returning Markdown, and GET /alerts returning JSON alert definitions.
Step 10: Create Next.js API routes
The Next.js App Router hosts two routes under app/api/.
ts
// app/api/health/route.tsimport { NextResponse } from "next/server";export function GET() { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });}
ts
// app/api/enforce/route.tsimport { type NextRequest, NextResponse } from "next/server";import { z } from "zod";import { AttendanceRecordSchema } from "../../../src/lib/types.js";import { runEnforcementPipeline } from "../../../src/services/enforcement-pipeline.js";import { pipelineConfig } from "../../../src/lib/config.js";export async function POST(req: NextRequest) { try { const body = await req.json() as Record<string, unknown>; const rawRecords = body.records; if (rawRecords == null || !Array.isArray(rawRecords)) { return NextResponse.json({ error: "Missing 'records' field" }, { status: 400 }); } const parsed = z.array(AttendanceRecordSchema).safeParse(rawRecords); if (!parsed.success) { return NextResponse.json({ error: JSON.stringify(z.treeifyError(parsed.error)) }, { status: 400 }); } const records = parsed.data.map(r => ({ ...r, scheduledDate: new Date(r.scheduledDate), checkedInAt: r.checkedInAt ? new Date(r.checkedInAt) : undefined, cancelledAt: r.cancelledAt ? new Date(r.cancelledAt) : undefined, })); const result = await runEnforcementPipeline(pipelineConfig, records); return NextResponse.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return NextResponse.json({ error: message }, { status: 500 }); }}
Expected output:GET /api/health returns 200 with { status: "ok" }. POST /api/enforce with a valid body returns 200 with enforcement results. Missing records returns a 400 error.
Step 11: Create the barrel export
src/index.ts re-exports all service factories and types so consumers can import from a single entry point.
ts
// src/index.tsexport { createAttendanceAnalyzer } from "./services/attendance-analyzer.js";export { createPolicyEnforcer } from "./services/policy-enforcer.js";export { createNotificationService } from "./services/notification-service.js";export { createRunbookBuilder } from "./services/runbook-builder.js";export { createAlertGenerator } from "./services/alert-generator.js";export { createHealthCheckGenerator } from "./services/health-check-generator.js";export { runEnforcementPipeline } from "./services/enforcement-pipeline.js";export { startFastifyServer } from "./api/fastify-server.js";export * from "./lib/types.js";
Step 12: Run the tests
The test suite covers every module with happy-path, error-path, and boundary cases. Run it with coverage:
terminal
pnpm test
Expected output:pnpm test exits 0 with all 100 tests passing across 15 test files. Coverage exceeds 90% across all thresholds (lines: ~99.5%, branches: ~91%, functions: 100%, statements: ~99.5%).
Each test file follows the same pattern:
Types tests (tests/lib/types.test.ts): Validate each Zod schema parses valid data, rejects invalid data, and applies defaults correctly.
LLM tests (tests/lib/llm.test.ts): Mock the OpenAI HTTP endpoint via MSW, verify generateEnforcementMessage returns text and usage. Error path mocks a 500 response.
Service tests (tests/services/): Each service factory is tested in isolation — imports are mocked with vi.mock. Tests verify happy path output shapes, error wrapping, and boundary conditions (empty input, edge case values).
Pipeline test (tests/services/enforcement-pipeline.test.ts): Mocks all sub-services and the Langfuse client. Verifies the pipeline orchestrator calls analyzers, generates messages, sends SMS, logs events, and returns the aggregated result.
Route tests (tests/app/api/enforce/route.test.ts): Tests the POST handler directly by constructing a NextRequest and calling the exported function. Mocks the pipeline module so no real API calls are made.
Here’s a sample test for the Policy Enforcer to show the pattern:
Expected output:pnpm test exits 0 with all tests passing.
Next steps
Add SMS templates — Replace the LLM-generated messages with configurable templates for warning, fee, and suspension notifications, giving studio managers full control of the wording
Add a dashboard page — Extend app/page.tsx with a history of enforcement runs, member infraction counts, and revenue recovered from fees, all rendered from the pipeline results
Integrate a real attendance system — Replace the manual POST /api/enforce request with a cron job or webhook listener that pulls attendance data from Mindbody, Glofox, or another studio management platform
Add payment collection — Connect the fee actions to a payment processor (Stripe, Square) so the system can charge no-show and late-cancel fees automatically instead of just sending SMS warnings