A project manager at a specialty trade contractor (e.g., electrical or plumbing) spends hours every week chasing lien waivers from subs before each draw request. Subcontractors ignore emails, and missing waivers delay payments. They need a system that sends automated requests, tracks status, and stores signed waivers with timestamps.
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 a Lien Waiver Collector — a REST API for specialty trade contractors (electrical, plumbing, HVAC) who need to automate lien waiver collection from subcontractors before each draw request. You’ll wire six REAA packages into a Next.js 16 + Hono stack, integrate DocuSign for eSignature delivery, Vercel Blob for signed PDF storage, and a provider-agnostic AI layer that drafts reminder emails via OpenAI or DeepSeek. By the end you’ll have a full-featured API server with automated waiver requests, status tracking, signed document storage with timestamps, and idempotent endpoints.
Prerequisites
Node.js 22+ and pnpm 10 installed
A Next.js 16+ project scaffold (used for type-checking and build tooling; the API runs as a standalone Hono server)
OpenAI API key (or DeepSeek API key as fallback)
DocuSign Developer account (free tier at demo.docusign.net) — access token and account ID
Vercel Blob storage token (BLOB_READ_WRITE_TOKEN)
Langfuse project keys (free tier at cloud.langfuse.com) — optional, for LLM observability
Familiarity with TypeScript and Hono
Step 1: Configure the project and environment variables
Start from the scaffolded Next.js 16 project. The key dependencies are already in package.json — six REAA packages for the document-pipeline foundation, plus integration packages for DocuSign, Vercel Blob, Vercel AI SDK, Langfuse, Hono, and Zod.
Open .env.example and confirm these environment variables are present:
Expected output: You have a .env.example with all service credentials listed and a constants.ts that centralizes runtime defaults.
Step 2: Define core types with Zod schemas
Create src/lib/types.ts with Zod schemas for every entity in the system. Each schema uses z.infer so the TypeScript types stay synchronized with the runtime validation. This file also exports const maps for enums so route handlers can reference values without magic strings.
ts
import { z } from "zod";export const WaiverType = { CONDITIONAL: "conditional", UNCONDITIONAL: "unconditional",} as const;export type WaiverType = (typeof WaiverType)[keyof typeof WaiverType];export const WaiverStatus = { PENDING: "pending", SENT: "sent", SIGNED: "signed", EXPIRED: "expired", VOID: "void",} as const;export
Create src/lib/errors.ts with typed error classes that the global error handler can pattern-match against:
Expected output: Two files — src/lib/types.ts with nine Zod schemas and src/lib/errors.ts with seven typed error classes — all passing pnpm typecheck.
Step 3: Build the persistence and memory services
The persistence layer uses @reaatech/a2a-reference-persistence — specifically InMemoryTaskStore, which provides task creation, status management, history, and artifact storage.
Expected output: Two service classes — PersistenceService wraps InMemoryTaskStore with domain methods like createWaiverTask, markWaiverSigned, and cancelWaiver; MemoryService wraps InMemoryMemoryStorage for event logging.
Step 4: Create the authentication middleware
Authenticate API requests using @reaatech/a2a-reference-auth. When API_KEY is set, validate the bearer token with ApiKeyStrategy. In development (no key), fall through with NoneStrategy.
Expected output: A Hono middleware that enforces bearer-token authentication when API_KEY is present, and passes through all requests in development mode.
Step 5: Wire the guardrail validation pipeline and idempotency middleware
The guardrail chain from @reaatech/guardrail-chain validates waiver payloads before processing. Create src/services/guardrail.ts:
ts
import { ChainBuilder, setLogger, ConsoleLogger, LRUCache, type Guardrail, type GuardrailResult, type ChainContext, generateCorrelationId, createChainContext } from "@reaatech/guardrail-chain";setLogger(new ConsoleLogger());class WaiverContentGuardrail implements Guardrail<string, string> { readonly id = "waiver-validation"; readonly name = "Waiver Content Validation"; readonly type = "input" as const; enabled = true; execute(input: string, context: ChainContext): Promise<GuardrailResult<string>> { void context; try { const data = JSON.parse(input) as Record<string, unknown>; if (typeof data.amount !== "number" || data.amount <= 0) { return Promise.resolve({ passed: false, output: input, error: new Error("Amount must be positive") }); } if (typeof data.projectId !== "string" || typeof data.subcontractorId !== "string") { return Promise.resolve({ passed: false, output: input, error: new Error("Missing required fields") }); } return Promise.resolve({ passed: true, output: input }); } catch { return Promise.resolve({ passed: false, output: input, error: new Error("Invalid JSON input") }); } }}const chain = new ChainBuilder() .withBudget({ maxLatencyMs: 500, maxTokens: 4000 }) .withGuardrail(new WaiverContentGuardrail()) .build();const resultCache = new LRUCache<string, { success: boolean; error?: string }>({ maxSize: 500, ttlMs: 300_000 });export async function runWaiverGuardrails(payload: string): Promise<{ success: boolean; error?: string }> { const cached = resultCache.get(payload); if (cached) { return cached; } const correlationId = generateCorrelationId(); createChainContext(payload, { maxLatencyMs: 500, maxTokens: 4000 }, { correlationId }); const result = await chain.execute(payload); const output = { success: result.success, error: result.error }; if (result.success) { resultCache.set(payload, output); } return output;}
The idempotency middleware prevents duplicate waiver creation. Create src/services/idempotency.ts:
Expected output: Two services — guardrail.ts validates waiver payloads before they reach the route handler (positive amount, required fields) and caches results; idempotency.ts ensures POST requests with the same Idempotency-Key header are only processed once.
Step 6: Integrate DocuSign and Vercel Blob for document pipeline
The DocuSign service sends envelopes for signature and processes Connect webhook callbacks. Create src/services/docusign.ts:
ts
import * as docusign from "docusign-esign";import { DocuSignApiError } from "../lib/errors.js";interface DocuSignConfig { accessToken: string; accountId: string; baseUrl: string;}let config: DocuSignConfig | undefined;export function configureDocuSign(cfg: DocuSignConfig) { config = cfg;}export async function createEnvelope(subcontractorEmail: string, subcontractorName
The Blob service stores signed PDFs. Create src/services/blob.ts:
ts
import { put, head, del, list } from "@vercel/blob";import { BlobStorageError } from "../lib/errors.js";export async function uploadWaiverPdf(buffer: Buffer, fileName: string): Promise<string> { try { const result = await put(fileName, buffer, { access: "public" }); return result.url; } catch (err) { const blobErr = err as { message?: string }; throw new BlobStorageError(blobErr.message || "Upload failed"); }}export async function getWaiverFile(blobPath: string) { try { return await head(blobPath); } catch (err) { const blobErr = err as { statusCode?: number; message?: string }; if (blobErr.statusCode === 404) return null; throw new BlobStorageError(blobErr.message || "Failed to fetch blob metadata"); }}export async function deleteWaiverFile(blobPath: string): Promise<void> { try { await del(blobPath); } catch (err) { const blobErr = err as { message?: string }; throw new BlobStorageError(blobErr.message || "Delete failed"); }}export async function listWaiverFiles(prefix: string) { try { return await list({ prefix }); } catch (err) { const blobErr = err as { message?: string }; throw new BlobStorageError(blobErr.message || "List failed"); }}
Expected output: Two integration services — docusign.ts wraps the docusign-esign SDK (create envelope, get status, download document, void, process webhook); blob.ts wraps @vercel/blob for upload, read, delete, and list. All errors are re-thrown as typed DocuSignApiError or BlobStorageError.
Step 7: Build the provider-agnostic AI service with Langfuse tracing
The AI service drafts reminder emails using either OpenAI (via Vercel AI SDK) or DeepSeek (via the OpenAI SDK with a custom base URL). Create src/services/ai.ts:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import OpenAI from "openai";function getModel() { if (process.env.DEEPSEEK_API_KEY && !process.env.OPENAI_API_KEY) { const client = new OpenAI({ baseURL: "https://api.deepseek.com", apiKey: process.env.DEEPSEEK_API_KEY, }); return { provider: "deepseek" as const, client, model: "deepseek-v4-flash" }; } return { provider: "openai" as const, client: undefined, model: undefined };}
Create src/services/langfuse.ts for observability:
Create src/services/mcp.ts as a re-export barrel for the @reaatech/mcp-server-core helpers:
ts
import { textContent, errorResponse, ToolResponseSchema, type ToolResponse, type RequestContext, envConfig, isProduction, isDevelopment, isTest, HealthStatusSchema, APP_VERSION } from "@reaatech/mcp-server-core";export { textContent, errorResponse, ToolResponseSchema, envConfig, isProduction, isDevelopment, isTest, HealthStatusSchema, APP_VERSION };export type { ToolResponse, RequestContext };
Expected output: Three services — ai.ts (provider-agnostic reminder generation with fallback), langfuse.ts (tracing for LLM and DocuSign calls), and mcp.ts (re-exports typed response helpers from mcp-server-core).
Step 8: Assemble the Hono API server with all routes
The Hono server mounts six routers under /api/v1/, applies global CORS, auth middleware, and a typed error handler. Create src/server/hono.ts:
ts
import { Hono } from "hono";import { cors } from "hono/cors";import { authMiddleware } from "../middleware/auth.js";import { errorHandler } from "./error-handler.js";import { envConfig, isDevelopment } from "../services/mcp.js";import waiversRouter from "./routes/waivers.js";import remindersRouter from "./routes/reminders.js";import webhooksRouter from "./routes/webhooks.js";import projectsRouter from "./routes/projects.js";import subsRouter from "./routes/subs.js";import healthRouter from "./routes/health.js";const app = new Hono();const corsOrigins = isDevelopment() ? "*" : (envConfig.CORS_ORIGIN || "https://example.com");app.use("*", cors({ origin: corsOrigins }));app.use("*", authMiddleware);app.onError(errorHandler);app.route("/api/v1/waivers", waiversRouter);app.route("/api/v1/reminders", remindersRouter);app.route("/api/v1/webhooks", webhooksRouter);app.route("/api/v1/projects", projectsRouter);app.route("/api/v1/subs", subsRouter);app.route("/api/v1/health", healthRouter);app.route("/health", healthRouter);export default app;
The error handler maps each typed exception to the correct HTTP status. Create src/server/error-handler.ts:
ts
import type { ErrorHandler } from "hono";import type { ContentfulStatusCode } from "hono/utils/http-status";import { HTTPException } from "hono/http-exception";import { WaiverNotFoundError, SubcontractorNotFoundError, ProjectNotFoundError, DocuSignApiError, BlobStorageError, ValidationError } from "../lib/errors.js";import { IdempotencyError } from "@reaatech/idempotency-middleware";export const errorHandler: ErrorHandler = (err, c) => { if (err instanceof WaiverNotFoundError || err instanceof SubcontractorNotFoundError || err instanceof ProjectNotFoundError) { return c.json({ error: err.name, message: err.message }, 404); } if (err instanceof ValidationError) { return c.json({ error: err.name, message: err.message, details: err.details }, 400); } if (err instanceof DocuSignApiError) { return c.json({ error: err.name, message: err.message }, (err.statusCode || 502) as ContentfulStatusCode); } if (err instanceof BlobStorageError) { return c.json({ error: err.name, message: err.message }, 502); } if (err instanceof IdempotencyError) { return c.json({ error: err.code, message: err.message }, err.getStatusCode() as ContentfulStatusCode); } if (err instanceof HTTPException) { return c.json({ error: "HTTPException", message: err.message }, err.status); } console.error("Unhandled error:", err); return c.json({ error: "InternalServerError", message: "An unexpected error occurred" }, 500);};
The waiver route orchestrates the full document-pipeline flow. Create src/server/routes/waivers.ts:
ts
import { Hono } from "hono";import { CreateLienWaiverSchema } from "../../lib/types.js";import type { LienWaiver } from "../../lib/types.js";import { WaiverNotFoundError, ValidationError } from "../../lib/errors.js";import { PersistenceService } from "../../services/persistence.js";import { IdempotencyService } from "../../services/idempotency.js";import { runWaiverGuardrails } from "../../services/guardrail.js";import { createEnvelope } from "../../services/docusign.js";import { uploadWaiverPdf } from "../../services/blob.js";import type { Task } from "@reaatech/a2a-reference-core";const persistence = new PersistenceService();const idempotencyService = new IdempotencyService();const router = new Hono();router.get("/", async (c) => { c.req.query(); const data = await persistence.listPendingWaivers(); const taskArray = (Array.isArray(data) ? data : []) as Task[]; const waivers = await Promise.all( taskArray.map(async (w: Task) => { const full = await persistence.getWaiverTask(w.id); return full; }) ); return c.json({ data: waivers.filter(Boolean) });});router.post("/", async (c) => { const key = c.req.header("Idempotency-Key") ?? ""; const body: unknown = await c.req.json(); return idempotencyService.execute( key, { method: "POST", path: "/api/v1/waivers", body }, async () => { const parsed = CreateLienWaiverSchema.safeParse(body); if (!parsed.success) { throw new ValidationError("Invalid waiver data", parsed.error.issues.map(i => i.message)); } const guardrailResult = await runWaiverGuardrails(JSON.stringify(parsed.data)); if (!guardrailResult.success) { throw new ValidationError(guardrailResult.error || "Guardrail validation failed"); } const id = crypto.randomUUID(); const now = new Date().toISOString(); const waiver: LienWaiver = { id, ...parsed.data, status: "pending", createdAt: now, updatedAt: now }; await persistence.createWaiverTask(waiver); // Generate waiver document and upload to blob const waiverContent = `Lien Waiver - ${parsed.data.waiverType} - $${String(parsed.data.amount)}`; const waiverDoc = Buffer.from(waiverContent); const blobUrl = await uploadWaiverPdf(waiverDoc, `waivers/${id}.txt`); // Send via DocuSign const envelopeId = await createEnvelope("subcontractor@example.com", "Subcontractor", blobUrl); // Update waiver with envelope and document info waiver.documentUrl = blobUrl; waiver.envelopeId = envelopeId; waiver.status = "sent"; return c.json(waiver, 201); } );});router.get("/:id", async (c) => { const task = await persistence.getWaiverTask(c.req.param("id")); if (!task) throw new WaiverNotFoundError(c.req.param("id")); return c.json(task);});router.delete("/:id", async (c) => { const task = await persistence.cancelWaiver(c.req.param("id")); if (!task) throw new WaiverNotFoundError(c.req.param("id")); return c.json({ success: true });});export default router;
Create the webhook route at src/server/routes/webhooks.ts which processes DocuSign Connect callbacks and completes the pipeline by storing the signed document:
ts
import { Hono } from "hono";import { ToolResponseSchema } from "../../services/mcp.js";import { IdempotencyService } from "../../services/idempotency.js";import { processConnectWebhook, downloadSignedDocument } from "../../services/docusign.js";import { PersistenceService } from "../../services/persistence.js";import { MemoryService } from "../../services/memory.js";import { uploadWaiverPdf } from "../../services/blob.js";const router = new Hono();const idempotencyService = new IdempotencyService();const persistence = new PersistenceService();const memory = new MemoryService();router.post("/docusign/connect", async (c) => { const key = c.req.header("Idempotency-Key") ?? ""; const body: Record<string, unknown> = await c.req.json(); return idempotencyService.execute( key, { method: "POST", path: "/api/v1/webhooks/docusign/connect", body }, async () => { const parsed = ToolResponseSchema.safeParse({ content: [{ type: "text", text: JSON.stringify(body) }] }); if (!parsed.success) { return c.json({ error: "Invalid webhook payload" }, 400); } const { envelopeId, status } = processConnectWebhook(body); if (status === "completed" || status === "signed") { const signedDoc = await downloadSignedDocument(envelopeId); const blobUrl = await uploadWaiverPdf(signedDoc, `waivers/${envelopeId}.pdf`); await persistence.markWaiverSigned(envelopeId, blobUrl); await memory.storeWaiverEvent({ id: envelopeId, tenantId: "default", content: "Document signed" }); } return c.json({ envelopeId, status, processed: true }); } );});export default router;
The remaining route files — reminders.ts, projects.ts, subs.ts, reports.ts, and health.ts — follow the same pattern. The health route uses HealthStatusSchema from mcp-server-core. Create src/server/routes/health.ts:
ts
import { Hono } from "hono";import { HealthStatusSchema, APP_VERSION, envConfig } from "../../services/mcp.js";const router = new Hono();router.get("/", (c) => { const health = HealthStatusSchema.parse({ status: "healthy", version: APP_VERSION, environment: envConfig.NODE_ENV, uptime: process.uptime(), timestamp: new Date().toISOString(), }); return c.json(health);});export default router;
Expected output: A fully wired Hono app at src/server/hono.ts with six route modules, global middleware, and a typed error handler. The waiver POST route chains guardrail validation → persistence → DocuSign → Blob upload in a single handler.
Step 9: Create the barrel export and confirm the Next.js config
Create src/index.ts as a barrel export so consumers can import any service from a single entry point:
ts
export { PersistenceService } from "./services/persistence.js";export { MemoryService } from "./services/memory.js";export { IdempotencyService } from "./services/idempotency.js";export * from "./services/mcp.js";export const SCAFFOLD_VERSION = "0.1.0" as const;
Confirm your next.config.ts is in place. The recipe uses Next.js for type-checking and build tooling, not for serving pages:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { /* config options here */};export default nextConfig;
Expected output: A src/index.ts barrel re-exporting all services plus a bare next.config.ts confirming the project skeleton.
Step 10: Write integration tests and verify the pipeline
Tests use Vitest with vi.mock to isolate each external dependency. Here’s the test for the waivers route at tests/api/routes/waivers.test.ts:
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: All tests pass (numFailedTests=0), coverage meets the 90% threshold on lines, branches, functions, and statements, and at least 3 total test files pass.
Next steps
Add database persistence — replace the in-memory InMemoryTaskStore with PostgreSQL or SQLite using @reaatech/a2a-reference-persistence’s database adapter for production durability
Extend the AI workflow — add a cron job that polls for overdue waivers and auto-sends escalated reminders via generateEscalationMessage
Add subcontractor onboarding — build a portal where subs can upload their own signed waivers without DocuSign, with automated validation against the guardrail chain
Deploy — run the Hono server with pnpm tsx src/server/hono.ts behind a reverse proxy on port 3001
type
WaiverStatus
=
(
typeof
WaiverStatus)[
keyof
typeof
WaiverStatus];
export const ReminderStatus = {
PENDING: "pending",
SENT: "sent",
FAILED: "failed",
} as const;
export type ReminderStatus = (typeof ReminderStatus)[keyof typeof ReminderStatus];
export const Channel = {
EMAIL: "email",
} as const;
export type Channel = (typeof Channel)[keyof typeof Channel];
export const ProjectStatus = {
ACTIVE: "active",
CLOSED: "closed",
} as const;
export type ProjectStatus = (typeof ProjectStatus)[keyof typeof ProjectStatus];
content: "You draft polite but firm lien-waiver reminder emails. Keep it concise and professional.",
}, {
role: "user",
content: `Write a reminder for ${subcontractorName} about an unpaid lien waiver of $${String(waiverAmount)} for project ${projectName}, ${String(daysOverdue)} days overdue.`,
system: "You draft polite but firm lien-waiver reminder emails for subcontractors in the construction industry. Keep each message to 3-4 sentences, professional and clear.",
prompt: `Write a reminder for ${subcontractorName} about a lien waiver of $${String(waiverAmount)} for project ${projectName}, ${String(daysOverdue)} days overdue.`,
});
return text;
} catch {
return getFallbackMessage(params);
}
}
export async function generateEscalationMessage(previousReminders: string[]): Promise<string> {
content: "You draft escalating follow-up messages for lien waivers.",
}, {
role: "user",
content: `Previous reminders: ${previousReminders.join(" | ")}. Write an escalated reminder.`,
}],
});
return completion.choices[0]?.message?.content || "This is our final reminder. Please submit your signed lien waiver immediately to avoid payment delays.";
}
const { text } = await generateText({
model: openai("gpt-5.2-mini"),
system: "You draft escalating follow-up messages for lien waivers. Each should be more urgent than the last.",
prompt: `Previous reminders: ${previousReminders.join(" | ")}. Write an escalated reminder.`,
});
return text;
} catch {
return "This is our final reminder. Please submit your signed lien waiver immediately to avoid payment delays.";
}
}
function getFallbackMessage(params: { subcontractorName: string; waiverAmount: number; projectName: string; daysOverdue: number }): string {
return `Dear ${params.subcontractorName}, this is a reminder to submit your lien waiver for $${String(params.waiverAmount)} on project ${params.projectName}, now ${String(params.daysOverdue)} days overdue. Please submit at your earliest convenience.`;