A 5-person B2B startup launches a new feature every two weeks. The marketing lead spends 2 days per month manually writing social media posts for LinkedIn, Twitter, and Facebook. They often miss key features or post inconsistently. They need a system that ingests internal product release notes and automatically generates a 30-day social media calendar with platform-specific copy and scheduling.
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 Product Launch to Social Media Calendar — a Next.js app that ingests product release notes (markdown with YAML frontmatter) and generates a 30-day social media calendar with platform-specific copy for LinkedIn, Twitter/X, and Facebook. You’ll use the Vercel AI SDK with a provider-agnostic adapter, Zod for schema validation, and six REAA packages for markdown handling, scaffolding, validation, linting, reporting, and observability.
By the end, you’ll have a working web app with a browser UI and a REST API that you can point at any OpenAI-compatible LLM provider.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
An OpenAI-compatible API key (OpenAI, OpenRouter, DeepSeek, or any provider serving an OpenAI-compatible endpoint)
Basic familiarity with TypeScript and Next.js App Router
A terminal and a code editor
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router and TypeScript:
This generates the standard scaffold: app/layout.tsx, app/page.tsx, tsconfig.json, next.config.ts, eslint.config.mjs, and a src/ directory.
Expected output: You see a new directory with the scaffolded project files.
Step 2: Install dependencies
Install the runtime and development dependencies. These include the Vercel AI SDK, the provider-agnostic OpenAI compatible adapter, the OpenAI client (for raw chat completions), Zod, the six REAA packages, p-limit for concurrency, and MSW and Vitest for testing:
Expected output: All dependencies installed and pinned to exact versions.
Step 3: Configure environment variables
Create .env.example with the provider-agnostic environment variables the app needs:
env
# Env vars used by agnostic-social-content-calendar.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_COMPATIBLE_BASE_URL=https://api.openai.com/v1OPENAI_COMPATIBLE_API_KEY=<your-api-key>LLM_MODEL=gpt-5.2
Copy it to .env.local and fill in your API key:
terminal
cp .env.example .env.local
Then edit .env.local to set OPENAI_COMPATIBLE_API_KEY to your actual key and adjust the model name if needed.
Expected output: You have .env.local with your real credentials (never committed).
Step 4: Create the TypeScript types
Create src/types.ts with all the domain types for the social calendar pipeline:
Expected output: A file with all the type definitions and a default config constant.
Step 5: Create the LLM adapter
Create src/lib/llm.ts — your provider-agnostic LLM wrapper. It uses createOpenAICompatible from @ai-sdk/openai-compatible for structured outputs via the AI SDK, and the raw openai client for freeform text generation with retry logic. You can swap any OpenAI-compatible endpoint by changing environment variables:
ts
import OpenAI from "openai";import { generateText, Output } from "ai";import { createOpenAICompatible } from "@ai-sdk/openai-compatible";import type { z } from "zod";import type { SocialPlatform } from "../types.js";const OPENAI_BASE_URL = process.env.OPENAI_COMPATIBLE_BASE_URL ?? "https://api.openai.com/v1";const OPENAI_API_KEY = process.env.OPENAI_COMPATIBLE_API_KEY ?? "";const rawClient = new OpenAI({ baseURL: OPENAI_BASE_URL, apiKey: OPENAI_API_KEY,});const provider = createOpenAICompatible({ baseURL: process.env.OPENAI_COMPATIBLE_BASE_URL ?? "https://api.openai.com/v1", name: "custom-provider",});function getModelId(): string { return process.env.LLM_MODEL ?? "gpt-5.2";}export async function generateContent(prompt: string): Promise<string> { const trimmed = prompt.trim(); if (trimmed.length === 0) { throw new Error("Prompt must not be empty"); } const modelId = getModelId(); const maxRetries = 3; let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const completion = await rawClient.chat.completions.create({ model: modelId, messages: [{ role: "user", content: trimmed }], }); return completion.choices[0]?.message?.content ?? ""; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); if (attempt < maxRetries) { await new Promise((r) => setTimeout(r, 1000 * attempt)); } } } throw lastError ?? new Error("LLM call failed after retries");}export async function generateStructured<T>( prompt: string, schema: z.ZodType<T>,): Promise<T> { const trimmed = prompt.trim(); if (trimmed.length === 0) { throw new Error("Prompt must not be empty"); } const result = await generateText({ model: provider.chatModel(getModelId()), prompt: trimmed, output: Output.object({ schema }), }); return result.output;}function platformPrompt( releaseNote: string, platform: SocialPlatform, tone: string,): string { const platformGuidelines: Record<SocialPlatform, string> = { linkedin: "Write a professional LinkedIn post (1300-1500 characters). Include 3-5 relevant hashtags and a clear call-to-action. Use a professional but engaging tone.", twitter: "Write a punchy Twitter/X post (under 280 characters). Include 2-3 relevant hashtags. Be concise and engaging.", facebook: "Write a conversational Facebook post (400-600 characters). Include 1-2 optional hashtags. Use a friendly, approachable tone.", }; return `Tone: ${tone}\n\nRelease note content:\n${releaseNote}\n\n${platformGuidelines[platform]}`;}export async function generateSocialPost( releaseNote: string, platform: SocialPlatform, tone?: string,): Promise<string> { const prompt = platformPrompt(releaseNote, platform, tone ?? "professional"); return generateContent(prompt);}
This module exports three functions:
generateContent — freeform text generation with retry logic (3 attempts with exponential backoff)
generateStructured — structured JSON output using generateText with Output.object
generateSocialPost — platform-aware social post generation with appropriate length and style guidelines
Expected output: A reusable LLM adapter that works with any OpenAI-compatible provider.
Step 6: Create the observability module
Create src/lib/observability.ts — a thin wrapper around @reaatech/agents-markdown-observability that collects pipeline events and can produce a dashboard summary:
Expected output: A logging facade that forwards to the REAA observability package.
Step 7: Create the release notes service
Create src/services/release-notes-service.ts. This service parses markdown with YAML frontmatter, validates it against the REAA schema, scaffolds templates, and extracts features:
ts
import type { AgentsMdDocument } from "@reaatech/agents-markdown";import { AgentsMdFrontmatterSchema, VERSION,} from "@reaatech/agents-markdown";import type { GenerateResult } from "@reaatech/agents-markdown-scaffold";import { generateFiles, previewGeneration } from "@reaatech/agents-markdown-scaffold";import { validate, formatValidationSummary } from "@reaatech/agents-markdown-validator";import { recordPipelineOperation, logPipelineEvent } from "../lib/observability.js";import type { ReleaseNote } from "../types.js";function parseFrontmatter(raw: string): { title: string
Expected output: A service that can parse, validate, and format release notes, and scaffold templates.
Step 8: Create the platform adapter
Create src/services/platform-adapter.ts. This module owns the prompt engineering for each social platform and uses assertNever for exhaustiveness checking:
ts
import { assertNever } from "@reaatech/agents-markdown";import type { SocialPlatform } from "../types.js";import { generateContent } from "../lib/llm.js";import { recordPipelineOperation } from "../lib/observability.js";const PLATFORM_CONFIG: Record< SocialPlatform, { length: string; hashtags: string; tone: string }> = { linkedin: { length: "Write 1300-1500 characters of professional, insightful content.", hashtags: "Include 3-5 relevant hashtags at the end, e.g. #Tech #Innovation #ProductLaunch", tone: "professional but engaging", }, twitter: { length: "Write under 280 characters — be punchy, direct, and engaging.", hashtags: "Include 2-3 relevant hashtags at the end", tone: "concise and engaging", }, facebook: { length: "Write 400-600 characters of conversational, approachable content.", hashtags: "Include 1-2 optional hashtags at the end", tone: "friendly, approachable", },};function platformPrompt( platform: SocialPlatform, features: string[], tone: string,): string { const cfg = PLATFORM_CONFIG[platform]; return [ `You are a marketing copywriter creating a ${platform} post.`, `Tone: ${tone}`, `Features to highlight: ${features.join(", ")}`, cfg.length, "Include a clear call-to-action (e.g., 'Try it now', 'Learn more', 'Share your thoughts').", cfg.hashtags, "Return ONLY the post content, no preamble or explanation.", ].join("\n\n");}export async function generateLinkedInPost( features: string[], tone: string,): Promise<string> { return recordPipelineOperation("format", async () => { const prompt = platformPrompt("linkedin", features, tone); return generateContent(prompt); });}export async function generateTwitterPost( features: string[], tone: string,): Promise<string> { return recordPipelineOperation("format", async () => { const prompt = platformPrompt("twitter", features, tone); return generateContent(prompt); });}export async function generateFacebookPost( features: string[], tone: string,): Promise<string> { return recordPipelineOperation("format", async () => { const prompt = platformPrompt("facebook", features, tone); return generateContent(prompt); });}export async function generatePlatformPost( platform: SocialPlatform, features: string[], tone: string,): Promise<string> { switch (platform) { case "linkedin": return generateLinkedInPost(features, tone); case "twitter": return generateTwitterPost(features, tone); case "facebook": return generateFacebookPost(features, tone); default: return assertNever(platform); }}
The assertNever call in the default branch gives you a compile-time error if you ever add a new platform without handling it in the switch.
Expected output: A platform adapter with platform-specific prompt templates and an exhaustive switch.
Step 9: Create the content planner
Create src/services/content-planner.ts. This module handles the deterministic scheduling logic: distributing 30 posts across 3 platforms, cycling through post types, skipping weekends, and using randomId and groupBy from @reaatech/agents-markdown:
Expected output: A deterministic planner that creates a 30-post schedule with no weekend dates.
Step 10: Create the calendar generator
Create src/services/calendar-generator.ts. This is the main orchestration function that ties planning and content generation together, using p-limit to cap concurrent LLM calls at 3:
Limits concurrency to 3 parallel LLM calls via pLimit
Per-post error handling: if a single post fails, it gets "[Generation failed]" as content and the pipeline continues
Expected output: The main generator that produces a full SocialCalendar from a release note.
Step 11: Create the calendar lint service
Create src/services/calendar-lint-service.ts. This service validates the generated calendar for common issues (empty posts, character limit violations, weekend scheduling) and produces both Markdown and JSON reports using the REAA reporter package:
ts
import type { Finding, LintResult } from "@reaatech/agents-markdown";import { reportMarkdownLintResult, reportJsonLintResult } from "@reaatech/agents-markdown-reporter";import type { SocialCalendar } from "../types.js";const MAX_CHARS: Partial<Record<string, number>> = { linkedin: 2000, twitter: 280, facebook: 1000,};function lintSocialCalendar(calendar: SocialCalendar): LintResult { const findings: Finding[] = []; for (let i = 0; i < calendar.posts.length; i++) { const post = calendar.posts[i]; if (!post.content || post.content.trim().length === 0) { findings.push({ rule: "empty-post", severity: "error", message: `Post ${post.id} has empty content`, location: { line: i + 1, column: 1 }, autoFixable: false, }); } const platformMax: number | undefined = MAX_CHARS[post.platform]; if (platformMax !== undefined && post.content.length > platformMax) { findings.push({ rule: "exceeds-char-limit", severity: "warning", message: `Post ${post.id} exceeds ${post.platform} char limit`, location: { line: i + 1, column: 1 }, autoFixable: false, }); } if (post.scheduledDate) { const day = new Date(post.scheduledDate).getDay(); if (day === 0 || day === 6) { findings.push({ rule: "weekend-scheduling", severity: "warning", message: `Post ${post.id} is scheduled on a weekend (${post.scheduledDate})`, location: { line: i + 1, column: 1 }, autoFixable: false, }); } } } const errorCount = findings.filter((f) => f.severity === "error").length; const warningCount = findings.filter((f) => f.severity === "warning").length; const infoCount = findings.filter((f) => f.severity === "info").length; const fixableCount = findings.filter((f) => f.autoFixable).length; return { path: "./calendar", findings, errorCount, warningCount, infoCount, fixableCount, };}export function formatCalendarReport(calendar: SocialCalendar): string { const result = lintSocialCalendar(calendar); return reportMarkdownLintResult(result);}export function formatCalendarJsonReport(calendar: SocialCalendar): string { const result = lintSocialCalendar(calendar); return reportJsonLintResult(result);}export { lintSocialCalendar as lintCalendar };
Expected output: A lint service that produces human-readable and machine-readable quality reports.
Step 12: Create the MCP server
Create src/api/mcp-server.ts — a thin wrapper that starts the REAA agents-markdown MCP server with its five built-in tools (lint_agents_md, validate_agents_md, validate_skill_md, scaffold_agent, get_examples):
ts
import { startMcpServer, createMcpServer } from "@reaatech/agents-markdown-mcp-server";export async function startCalendarMcpServer(): Promise<void> { await startMcpServer("stdio");}export function createCalendarMcpServer() { return createMcpServer();}
Expected output: An MCP server entry point for the REAA tooling.
Step 13: Create the API routes
Create app/api/generate/route.ts — the POST endpoint that accepts release notes and returns a generated calendar:
Create app/api/calendar/route.ts — the GET endpoint that retrieves the last generated calendar in JSON or Markdown format:
ts
import { type NextRequest, NextResponse } from "next/server";let lastCalendar: unknown = null;export function setLastCalendar(calendar: unknown): void { lastCalendar = calendar;}export function getLastCalendar(): unknown { return lastCalendar;}export async function GET(req: NextRequest): Promise<NextResponse> { if (!lastCalendar) { return NextResponse.json( { error: "no calendar generated yet" }, { status: 404 }, ); } const format = req.nextUrl.searchParams.get("format"); if (format === "markdown") { // Import lazily to avoid circular deps const { formatCalendarReport } = await import( "../../../src/services/calendar-lint-service.js" ); const report = formatCalendarReport(lastCalendar as Parameters<typeof formatCalendarReport>[0]); return NextResponse.json({ report }, { status: 200 }); } return NextResponse.json({ calendar: lastCalendar }, { status: 200 });}
The calendar route uses a lazy dynamic import for formatCalendarReport to avoid a circular dependency with the generate route — the generate route imports setLastCalendar from the calendar route, and the calendar route imports formatCalendarReport from the lint service only when needed.
Expected output: Two API routes — POST /api/generate to create calendars and GET /api/calendar to retrieve them.
Step 14: Create the main entry point
Create src/index.ts with re-exports so other modules can import everything from a single entry:
ts
export { parseReleaseNotes, scaffoldReleaseNoteTemplate, formatReleaseNote } from "./services/release-notes-service.js";export { generateCalendar } from "./services/calendar-generator.js";export { summarizeCalendar } from "./services/content-planner.js";export { formatCalendarReport, formatCalendarJsonReport } from "./services/calendar-lint-service.js";export { startCalendarMcpServer, createCalendarMcpServer } from "./api/mcp-server.js";export type { ReleaseNote, SocialPost, SocialCalendar, CalendarConfig, SocialPlatform, PostType, GenerateRequest, GenerateResponse } from "./types.js";
Expected output: A single entry point that re-exports all public APIs.
Step 15: Create the browser UI
Replace the scaffold placeholder app/page.tsx with a form that accepts release notes and displays the generated calendar:
All external network calls are mocked using vi.mock so tests run without real API keys. Each test file covers happy path, error path, and boundary cases for its module. Run the tests with:
terminal
pnpm test
Expected output: All tests pass with numFailedTests=0 and coverage above 90%.
Step 17: Verify the build
Run the full verification suite:
terminal
pnpm typecheckpnpm lintpnpm test
All three must exit with code 0. If type errors appear, fix them — common issues include missing .js extensions on relative imports and type mismatches in mocked functions.
Once the verification suite passes, start the dev server:
terminal
pnpm dev
Then test the API with curl:
terminal
curl -X POST http://localhost:3000/api/generate \ -H "Content-Type: application/json" \ -d '{ "releaseNotes": "---\ntitle: V2.5 Launch Wave\ndate: 2026-06-10\nimpact: Major UX overhaul\n---\n- Dark mode support across all screens\n- SSO integration with Okta and Google\n- Performance improvements (40% faster load)\n- New analytics dashboard", "tone": "professional" }'
Your terminal returns a JSON response with a calendar containing 30 posts across LinkedIn, Twitter, and Facebook, plus a report with the lint results.
Expected output: A working app with both a browser UI and a REST API that generates a full 30-day social media calendar from release notes.
Next steps
Extend the platform list — add support for Instagram, TikTok, or Mastodon by adding new entries to SocialPlatform type and the PLATFORM_CONFIG in the platform adapter
Add image generation — integrate with an image generation API (DALL-E, Stable Diffusion) to auto-generate social media card images for each post
Persist the calendar — replace the in-memory lastCalendar store with a database (SQLite, Postgres) or file-based storage so calendars survive server restarts
Add scheduled posting — wire the output into a scheduler (e.g., cron or a queue) that auto-publishes posts to each platform’s API on their scheduled dates
Custom post types — let users define their own post types and distribution ratios through the CalendarConfig