A 15-person marketing agency spends 8 hours/week manually transcribing client calls and extracting action items. Tasks get lost in email threads, and follow-ups slip through the cracks. The CEO wants a way to automatically capture decisions, assign owners, and ensure nothing falls off the radar without hiring a project manager.
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 Next.js application that transforms Zoom-style meeting transcripts into assigned action items, routes them to the right team, and automatically follows up via Slack for up to 7 days. You’ll use the Vercel AI SDK with OpenAI for LLM extraction, the REAA agent packages for memory persistence, intelligent routing, and markdown formatting, and the Slack Web API for follow-up messaging.
By the end, you’ll have seven API endpoints that cover the full pipeline — transcript ingestion, action-item CRUD, memory retrieval, and daily follow-up processing — plus an integration test suite with 90%+ coverage.
Prerequisites
Node.js 22+ and pnpm 10+ installed on your system
An OpenAI API key (for LLM extraction and embeddings)
A Slack bot token with chat:write scope and a channel ID to post follow-up messages
Basic familiarity with TypeScript, Next.js App Router, and REST API design
Step 1: Scaffold the project
Create a new Next.js project and install all dependencies. Start from an empty directory.
Set "type": "module" in package.json and add the following scripts:
terminal
pnpm pkg set type=module scripts.dev="next dev" scripts.build="next build" scripts.start="next start" scripts.lint="eslint ." scripts.typecheck="tsc --noEmit" scripts.test="vitest run --coverage --reporter=json --outputFile=vitest-report.json"
Install the production dependencies. The REAA packages provide agent memory, extraction, retrieval, handoff routing, and markdown formatting. The AI SDK and Slack Web API handle LLM calls and messaging.
Create a .env.example file with the environment variables you’ll need:
env
# Env vars used by agnostic-meeting-notes-to-actions.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# OpenAIOPENAI_API_KEY=<your-openai-key># SlackSLACK_BOT_TOKEN=<your-slack-bot-token>SLACK_DEFAULT_CHANNEL=<channel-id>
Copy it to .env.local and fill in your real values:
terminal
cp .env.example .env.local
Expected output: A package.json with all deps pinned to exact versions, no ^ or ~ prefixes, plus next.config.ts and tsconfig.json. Your .env.local has your API keys.
Step 2: Define the types and Zod schemas
Create the core TypeScript interfaces and validation schemas that every service depends on.
src/types/index.ts defines the data shapes for action items, meeting notes, and follow-up status:
Expected output: Two clean TypeScript files that compile without errors. The schemas enforce that transcripts have non-empty content and action items have non-empty descriptions and assignees.
Step 3: Create the LLM integration layer
The LLM module wraps the Vercel AI SDK with OpenAI to extract structured action items from transcripts and generate follow-up messages.
src/lib/llm.ts:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import type { ConversationTurn } from "../types";import type { ActionItem } from "../types";import { MeetingSummarySchema } from "../types/schemas";export function createLlmClient() { return openai("gpt-5.2-mini");}export async function generateStructuredOutput( transcript: ConversationTurn[]): Promise<{ summary: string; keyDecisions: string[]; actionItems: ActionItem[] }> { const model = createLlmClient(); const { text } = await generateText({ model, system: "You are a project manager. Extract action items with assignees and priorities from this meeting transcript. Identify key decisions.", prompt: JSON.stringify(transcript), }); const parsed = MeetingSummarySchema.parse(JSON.parse(text)); return { summary: parsed.summary, keyDecisions: parsed.keyDecisions, actionItems: parsed.actionItems.map((item) => ({ ...item, id: crypto.randomUUID(), })), };}export async function generateFollowUpMessage( item: ActionItem, day: number): Promise<string> { const model = createLlmClient(); const dayStr = String(day); const { text } = await generateText({ model, system: "Write a courteous follow-up for this task. Day N means N check-ins have passed.", prompt: `Action item: ${item.description}\nAssignee: ${item.assignee}\nPriority: ${item.priority}\nDay: ${dayStr}`, }); return text;}
Two functions, both using generateText from the AI SDK:
generateStructuredOutput sends the transcript as JSON to the model with a project-manager system prompt, then parses the response through MeetingSummarySchema to guarantee the shape.
generateFollowUpMessage generates a courteous Slack message for a specific action item at a given day.
Expected output: A module that depends on process.env.OPENAI_API_KEY (read at runtime by the OpenAI provider) and exports both functions. No compile errors.
Step 4: Build the Agent Memory services
Three packages provide memory capabilities. Start with the facade that wraps AgentMemory for extracting and retrieving conversational memories.
src/services/memory-service.ts:
ts
import { AgentMemory, OpenAILLMProvider, MemoryType } from "@reaatech/agent-memory";import type { ConversationTurn } from "../types";export class MemoryService { private memory: AgentMemory; constructor() { this.memory = new AgentMemory({ storage: { provider: "memory" }, embedding: { provider: "openai", model: "text-embedding-3-small", apiKey: process.env.OPENAI_API_KEY as string, }, extraction: { llmProvider: new OpenAILLMProvider({ apiKey: process.env.OPENAI_API_KEY as string, model: "gpt-4o-mini", }), enabledTypes: [ MemoryType.FACT, MemoryType.PREFERENCE, MemoryType.DECISION, ], batchSize: 10, confidenceThreshold: 0.7, }, }); } async extractAndStoreMemories(conversation: ConversationTurn[]): Promise<Array<Record<string, unknown>>> { const result: unknown = await this.memory.extractAndStore(conversation); return result as Array<Record<string, unknown>>; } async retrieveRelevantContext(query: string, limit?: number): Promise<Array<Record<string, unknown>>> { const result: unknown = await this.memory.retrieve(query, { limit: limit ?? 5 }); return result as Array<Record<string, unknown>>; } async close(): Promise<void> { await this.memory.close(); }}
src/services/retrieval-service.ts adds standalone semantic retrieval with the MemoryRetriever and ContextInjector:
ts
import { MemoryRetriever, ContextInjector, RetrievalStrategy,} from "@reaatech/agent-memory-retrieval";import { InMemoryMemoryStorage } from "@reaatech/agent-memory-storage";import { OpenAIEmbeddingProvider } from "@reaatech/agent-memory-embedding";import type { Memory } from "@reaatech/agent-memory-core";export class RetrievalService { private retriever: MemoryRetriever; private injector: ContextInjector; constructor() { const storage = new InMemoryMemoryStorage(); const embedder = new OpenAIEmbeddingProvider({ apiKey: process.env.OPENAI_API_KEY as string, model: "text-embedding-3-small", }); this.retriever = new MemoryRetriever(storage, embedder, { defaultLimit: 5, useCrossEncoder: false, diversityFactor: 0.3, strategies: [RetrievalStrategy.SEMANTIC, RetrievalStrategy.RECENCY], }); this.injector = new ContextInjector(100000, 4); } async retrieveMemories(query: string, limit?: number): Promise<Array<Record<string, unknown>>> { const result: unknown = await this.retriever.retrieve(query, { limit: limit ?? 5 }); return result as Array<Record<string, unknown>>; } async buildMemoryContext(memories: Array<Record<string, unknown>>, tokenBudget: number): Promise<string> { if (memories.length === 0) { return ""; } const ctxArg: unknown = memories; return await this.injector.injectMemoriesIntoContext([], ctxArg as Array<Memory>, tokenBudget); }}
src/services/extraction-service.ts provides a standalone memory extraction path using MemoryExtractor:
ts
import { MemoryExtractor } from "@reaatech/agent-memory-extraction";import { MemoryType, OpenAILLMProvider } from "@reaatech/agent-memory";import { OpenAIEmbeddingProvider } from "@reaatech/agent-memory-embedding";import type { ConversationTurn } from "../types";export class ExtractionService { private extractor: MemoryExtractor; constructor() { this.extractor = new MemoryExtractor( new OpenAILLMProvider({ apiKey: process.env.OPENAI_API_KEY as string, model: "gpt-4o-mini", }), new OpenAIEmbeddingProvider({ apiKey: process.env.OPENAI_API_KEY as string, model: "text-embedding-3-small", }), { batchSize: 10, confidenceThreshold: 0.7, enabledTypes: [ MemoryType.FACT, MemoryType.PREFERENCE, MemoryType.DECISION, ], tenantId: "default", }, ); } async extractFromConversation(conversation: ConversationTurn[]): Promise<Record<string, unknown>> { const result: unknown = await this.extractor.extractFromConversation(conversation); return result as Record<string, unknown>; }}
Expected output: Three service classes that compile cleanly. MemoryService stores and retrieves conversational memories, RetrievalService adds configurable semantic strategies, and ExtractionService provides a dedicated extraction pipeline.
Step 5: Build the Handoff routing service
The handoff service uses CapabilityBasedRouter and AgentRegistry to route action items to the most appropriate agent.
src/services/handoff-service.ts:
ts
import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import type { HandoffPayload, RoutingDecision, Specialization, KeyFact, Intent, Entity, OpenItem, UserMetadata, ConversationState, HandoffTrigger,} from "@reaatech/agent-handoff";import type { ActionItem } from "../types";export class HandoffService { private router: CapabilityBasedRouter; private registry: AgentRegistry; constructor() { this.router = new CapabilityBasedRouter
The router scores agents based on skill match (40%), domain match (30%), load factor (20%), and language match (10%). It returns one of three decision types: "primary" (confident match), "clarification" (ambiguous), or "fallback" (no match).
Expected output: A service that registers three agents and can route any ActionItem through the capability-based router.
Step 6: Build the Slack integration service
The Slack service wraps the official @slack/web-api client to post messages and reminders.
src/services/slack-service.ts:
ts
import { WebClient, ErrorCode } from "@slack/web-api";import type { ActionItem } from "../types";import { generateFollowUpMessage } from "../lib/llm";export class SlackService { private web: WebClient; private defaultChannel: string; constructor() { this.web = new WebClient(process.env.SLACK_BOT_TOKEN); this.defaultChannel = process.env.SLACK_DEFAULT_CHANNEL as string; } async postMessage(channel: string, text: string): Promise<{ ok: boolean; ts?: string }> { try { const result = await this.web.chat.postMessage({ text, channel }); return { ok: true, ts: result.ts }; } catch (error: unknown) { if ( error && typeof error === "object" && "code" in error && (error as Record<string, unknown>).code === ErrorCode.PlatformError ) { throw new Error( `Slack API error: ${(error as Record<string, unknown>).message as string}`, ); } throw error; } } async postReminder(item: ActionItem, day: number): Promise<void> { const text = await generateFollowUpMessage(item, day); await this.postMessage(this.defaultChannel, text); }}
The postReminder method chains an LLM-generated message with a Slack API call, so each daily check-in gets a courteous, context-aware message.
Expected output: A class that reads SLACK_BOT_TOKEN and SLACK_DEFAULT_CHANNEL from environment variables and sends messages through the Slack API.
Step 7: Create the Markdown formatting utilities
The markdown module uses @reaatech/agents-markdown utility functions — assertNever, groupBy, randomId — to format meeting summaries and follow-up digests.
src/lib/markdown.ts:
ts
import { assertNever, groupBy, randomId } from "@reaatech/agents-markdown";import type { ActionItem, MeetingNotes } from "../types";function formatPriority(p: ActionItem["priority"]): string { switch (p) { case "high": return "High"; case "medium": return "Medium"; case "low": return "Low"; default: return assertNever(p); }}export function formatMeetingSummary(notes: MeetingNotes): string { let md = `# Meeting: ${notes.title}\n\n`; md += `**Date:** ${notes.date.toISOString().split("T")[0]}\n`; if (notes.participants.length > 0) { md += `**Participants:** ${notes.participants.join(", ")}\n`; } md += `\n## Summary\n\n${notes.summary}\n\n`; md += `## Action Items\n\n`; if (notes.actionItems.length === 0) { md += "No action items.\n\n"; return md; } const grouped = groupBy(notes.actionItems, (item: ActionItem) => item.priority); const priorityOrder: ActionItem["priority"][] = ["high", "medium", "low"]; for (const p of priorityOrder) { const items = Object.prototype.hasOwnProperty.call(grouped, p) ? grouped[p] : []; if (items.length === 0) continue; const header = formatPriority(p); md += `### ${header}\n\n`; md += `| Description | Assignee | Status |\n`; md += `| --- | --- | --- |\n`; for (const item of items) { md += `| ${item.description} | ${item.assignee} | ${item.status} |\n`; } md += "\n"; } return md;}export function formatFollowUpDigest(items: ActionItem[], day: number): string { const digestId = randomId(); const dayLabel = String(day); let md = `## Follow-Up — Day ${dayLabel}\n\n`; md += `**Digest ID:** ${digestId}\n\n`; if (items.length === 0) { md += `No pending action items.\n`; return md; } md += `The following items still need attention:\n\n`; items.forEach((item, i) => { const index = String(i + 1); md += `${index}. **${item.description}** — assigned to ${item.assignee} (${item.priority} priority, status: ${item.status})\n`; }); return md;}
formatMeetingSummary groups action items by priority using groupBy(), then renders them in priority tables. formatFollowUpDigest assigns a unique randomId() and renders a numbered list of pending items.
Expected output: Functions that produce clean Markdown strings. The empty-action-items case returns “No action items.” rather than an empty document.
Step 8: Build the core business logic services
Three services implement the recipe’s core workflow: processing transcripts, managing action items, and orchestrating follow-ups.
src/services/action-item-service.ts — an in-memory CRUD store for action items:
src/services/meeting-processor.ts — the orchestration core that chains memory extraction with LLM extraction:
ts
import type { ConversationTurn, MeetingNotes, ActionItem } from "../types";import type { MemoryService } from "./memory-service";import { generateStructuredOutput } from "../lib/llm";export class MeetingProcessor { private memoryService: MemoryService; constructor(memoryService: MemoryService) { this.memoryService = memoryService; } async processTranscript(input: { transcript: ConversationTurn[]; meetingTitle?: string; participants?: string[]; }): Promise<MeetingNotes> { await this.memoryService.extractAndStoreMemories(input.transcript); let result: { summary: string; keyDecisions: string[]; actionItems: ActionItem[] }; try { result = await generateStructuredOutput(input.transcript); } catch { throw new Error("LLM extraction failed"); } const id = crypto.randomUUID(); const notes: MeetingNotes = { id, title: input.meetingTitle ?? "Untitled Meeting", date: new Date(), participants: input.participants ?? [], transcript: input.transcript, actionItems: result.actionItems.map((item) => ({ ...item, sourceTranscriptId: id, })), summary: result.summary, }; return notes; }}
src/services/follow-up-service.ts — manages the 7-day follow-up lifecycle:
ts
import type { ActionItem, FollowUpStatus } from "../types";import type { SlackService } from "./slack-service";export class FollowUpService { private statuses: Map<string, FollowUpStatus> = new Map(); private items: Map<string, ActionItem> = new Map(); private slackService: SlackService; constructor(slackService: SlackService) { this.slackService = slackService; } scheduleFollowUps(items: ActionItem[]): FollowUpStatus[] { const created: FollowUpStatus[] = []; for (const item of items) { if (item.status === "done") continue; const status: FollowUpStatus = { actionItemId: item.id, day: 0, acknowledged: false, }; this.statuses.set(item.id, status); this.items.set(item.id, item); created.push(status); } return created; } processDailyFollowUp(day: number): FollowUpStatus[] { const processed: FollowUpStatus[] = []; for (const status of this.statuses.values()) { if (status.day !== day || status.acknowledged) continue; const item = this.items.get(status.actionItemId) as ActionItem; void this.slackService.postReminder(item, day); status.postedAt = new Date(); status.day = day + 1; processed.push(status); } return processed; } acknowledgeItem(id: string): void { const status = this.statuses.get(id); if (!status) throw new Error("not found"); status.acknowledged = true; }}
Expected output: Three self-contained service classes with clear responsibilities. The MeetingProcessor chains memory storage with LLM extraction; processDailyFollowUp only fires on unacknowledged items at matching day numbers.
Step 9: Create the API routes
Seven endpoint handlers expose the full pipeline as REST API routes. Create the directory structure under app/api/.
Expected output: Six route files under app/api/ covering seven HTTP endpoints. Each handler validates input with Zod and returns appropriate HTTP status codes (200, 201, 400, 404, 500).
Step 10: Set up the test infrastructure
Create the MSW server to mock OpenAI API calls, plus the Vitest configuration.
Expected output: An MSW server that intercepts POST https://api.openai.com/v1/responses and returns controlled fixture data. The Vitest config restricts coverage to runtime code only (route handlers and src/), excluding .tsx UI files.
Step 11: Write the tests
Create tests in tests/ mirroring the src/ structure. Tests use vi.mock() for REAA packages and MSW for the OpenAI endpoint — no live HTTP calls.
Expected output: A test suite covering happy paths, error cases, and boundary conditions. All tests pass with pnpm vitest run.
Step 12: Create the frontend and barrel exports
Replace the scaffolded app/page.tsx with a server component that documents the API:
app/page.tsx:
tsx
export default function Home() { return ( <main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem", fontFamily: "system-ui, sans-serif" }}> <h1>Meeting notes to action items with Slack followup</h1> <p style={{ fontSize: "1.2rem", color: "#666" }}> Turn Zoom transcripts into assigned tasks and auto-follow up via Slack for 7 days. </p> <h2>API Endpoints</h2> <ul> <li><code>POST /api/process-transcript</code> — Submit a meeting transcript for action item extraction</li> <li><code>GET /api/action-items</code> — List action items (optional <code>?status=</code> filter)</li> <li><code>POST /api/action-items</code> — Create a manual action item</li> <li><code>PATCH /api/action-items/:id</code> — Update action item status</li> <li><code>POST /api/follow-up</code> — Process daily follow-ups</li> <li><code>POST /api/follow-up/acknowledge</code> — Acknowledge a follow-up</li> <li><code>POST /api/retrieve-memories</code> — Query stored meeting memories</li> </ul> <h2>Environment Variables</h2> <pre style={{ background: "#f5f5f5", padding: "1rem", borderRadius: 4 }}>{`OPENAI_API_KEY=<your-openai-key>SLACK_BOT_TOKEN=<your-slack-bot-token>SLACK_DEFAULT_CHANNEL=<channel-id>`} </pre> </main> );}
And a src/index.ts barrel export for the public API:
ts
export type { ConversationTurn, ActionItem, MeetingNotes, FollowUpStatus } from "./types";export { ActionItemSchema, ProcessTranscriptSchema, MeetingSummarySchema, FollowUpDaySchema, ActionItemIdSchema } from "./types/schemas";export { createLlmClient, generateStructuredOutput, generateFollowUpMessage } from "./lib/llm";export { formatMeetingSummary, formatFollowUpDigest } from "./lib/markdown";export { MemoryService } from "./services/memory-service";export { ExtractionService } from "./services/extraction-service";export { RetrievalService } from "./services/retrieval-service";export { HandoffService } from "./services/handoff-service";export { SlackService } from "./services/slack-service";export { MeetingProcessor } from "./services/meeting-processor";export { ActionItemService } from "./services/action-item-service";export { FollowUpService } from "./services/follow-up-service";
Expected output: A clean documentation page and a barrel export that lets consumers import anything with import { MeetingProcessor } from "meeting-notes-to-actions".
Step 13: Run the verification pipeline
Run typechecking, linting, and tests to verify everything works:
terminal
pnpm typecheck
TypeScript should exit with zero errors. Next, run the linter:
terminal
pnpm lint
No warnings or errors. Finally, run the full test suite with coverage:
terminal
pnpm test
Expected output: All tests pass with numFailedTests: 0. Coverage thresholds of 90% on lines, branches, functions, and statements are met for all runtime code under src/**/*.ts and app/**/route.ts.
Next steps
Add a database backend — Replace the in-memory Map storage in ActionItemService and FollowUpService with PostgreSQL or SQLite so data persists across restarts
Extend the follow-up window — Modify FollowUpDaySchema to support longer intervals (e.g., max 30) and add a configurable cron scheduler instead of manual day-triggered processing
Add a Slack slash command — Create a /meeting-summary slash command that accepts a transcript URL and returns action items directly in Slack