Small marketing teams spend days bouncing between copy drafting, compliance checks, and channel posting. Without orchestration, brand voice slips and publishing deadlines get missed because no one agent owns the full workflow.
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.
Small marketing teams spend days bouncing between copy drafting, compliance checks, and channel posting. Without orchestration, brand voice slips and publishing deadlines get missed. This recipe builds a multi-agent marketing mesh with Vertex AI — a content writer agent drafts posts, a brand reviewer scores them for alignment, and a scheduler agent publishes to LinkedIn, Twitter, blog, or email — all gated by an automated confidence threshold. If the score is too low, the content loops back for revision before it ever reaches a live channel.
You’ll define each agent in an @reaatech/agent-mesh registry, orchestrate them as a LangGraph state graph, add input and output guardrails with @presidio-dev/hai-guardrails, trace every LLM call with Langfuse, and surface the whole workflow in a live Next.js dashboard. This tutorial is for TypeScript developers who know Next.js basics and want a practical introduction to LLM agent orchestration.
Prerequisites
Node.js 22+ and pnpm 10+
A Google Cloud project with the Vertex AI API enabled and a service account key for GOOGLE_APPLICATION_CREDENTIALS
A Langfuse account (free tier at cloud.langfuse.com) — get your public and secret keys from Project Settings
TypeScript familiarity and basic Next.js App Router knowledge
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router and install all dependencies. The exact pinned versions are in package.json — note the @reaatech/* packages for the agent mesh, @google-cloud/vertexai for Gemini access, @langchain/langgraph for workflow orchestration, zod for validation, zustand for client-side state, and langfuse for observability.
Expected output: pnpm prints success messages and the install finishes without errors.
Step 2: Configure environment variables
Copy the .env.example to .env.local and fill in your credentials. Every env var the recipe uses is listed here — never commit real values to version control.
Expected output: File saved. Your GCP project must have Vertex AI enabled and GOOGLE_APPLICATION_CREDENTIALS pointing to a valid service account key.
Step 3: Define campaign types and Zod schemas
Create src/types/campaign.ts with the core data types. The Campaign interface holds the campaign metadata, CampaignInput is what the API accepts, and WorkflowState tracks the mesh workflow progress through its stages.
Expected output: TypeScript compiles without errors. Channel is a union of the four supported platforms and the Zod schemas enforce minimum string lengths.
Step 4: Set up the Vertex AI client
Create src/lib/vertex.ts as the thin wrapper around @google-cloud/vertexai. It lazily initializes the Vertex AI client and exposes generateContent for synchronous generation and generateContentStream for streaming responses.
ts
import { VertexAI } from "@google-cloud/vertexai";let vertexAI: VertexAI | null = null;function getVertexAI(): VertexAI | null { const project = process.env.GOOGLE_CLOUD_PROJECT; const location = process.env.GOOGLE_CLOUD_LOCATION; if (!project || !location) { return null; } if (!vertexAI) { vertexAI = new VertexAI({ project, location }); } return vertexAI;}export function getGenerativeModel(modelName?: string) { const ai = getVertexAI(); if (!ai) { throw new Error( "Vertex AI not configured: GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION must be set" ); } const model = modelName ?? process.env.VERTEX_MODEL_NAME ?? "gemini-2.5-flash"; return ai.getGenerativeModel({ model });}export async function generateContent( prompt: string, systemInstruction?: string, modelName?: string): Promise<string> { const model = getGenerativeModel(modelName); const result = await model.generateContent({ contents: [{ role: "user", parts: [{ text: prompt }] }], systemInstruction: systemInstruction ? { role: "system", parts: [{ text: systemInstruction }] } : undefined, }); const candidates = result.response.candidates; const candidate = candidates ? candidates[0] : undefined; const content = candidate?.content; const parts = content?.parts; const part = parts ? parts[0] : undefined; return part?.text ?? "";}export async function* generateContentStream( prompt: string): AsyncIterable<string> { const model = getGenerativeModel(); const streamResult = await model.generateContentStream({ contents: [{ role: "user", parts: [{ text: prompt }] }], }); for await (const chunk of streamResult.stream) { const part = chunk.candidates?.[0]?.content?.parts?.[0]; if (part?.text) { yield part.text; } }}
Expected output: The generateContent function sends a prompt to Gemini and returns the response text. Without credentials, getGenerativeModel throws a clear configuration error.
Step 5: Initialize the agent mesh registry
Create src/mesh/registry.ts to initialize the agent registry from the @reaatech/agent-mesh-registry package. The registry discovers and manages the mesh agents.
ts
import { initRegistry, registryState, setupSighupHandler } from "@reaatech/agent-mesh-registry";import type { AgentConfig } from "@reaatech/agent-mesh";export async function initializeMeshRegistry(): Promise<void> { await initRegistry(); setupSighupHandler();}export function getAgent(agentId: string): AgentConfig | null { return registryState.getAgent(agentId) ?? null;}export function getDefaultAgent(): AgentConfig | null { return registryState.defaultAgent ?? null;}export function getAllAgentIds(): string[] { if (!registryState.isLoaded) { return []; } return registryState.getAgentIds();}
Expected output: When initializeMeshRegistry is called, it loads available agents from the configured agents directory and registers them. You can look up individual agents by ID or list all registered agent IDs.
Step 6: Implement the agent router
Create src/mesh/router.ts to dispatch campaign work to individual mesh agents via the @reaatech/agent-mesh-router package. The router handles MCP-based dispatch with circuit-breaker awareness and timeout logging.
Expected output: The router dispatches structured conversation turns to the right agent and reports latency and errors through the observability layer.
Step 7: Build the confidence gate
Create src/mesh/confidence.ts to evaluate whether a campaign’s brand-alignment score is high enough to proceed. The checkBrandAlignment function is the critical gate — if the reviewer’s score meets the threshold, the scheduler gets control; otherwise the content loops back for revision.
Expected output: A score of 0.85 or above passes the gate and routes to the scheduler. Scores below 0.85 fall through to the mesh confidence evaluator which may route, fall back, or request clarification.
Step 8: Add input and output guardrails
Create src/guardrails/index.ts with content safety checks using @presidio-dev/hai-guardrails. Input guardrails check user-submitted content for injections, PII, and secrets. Output guardrails check generated content before it’s published.
ts
import { GuardrailsEngine, injectionGuard, piiGuard, secretGuard, SelectionType,} from "@presidio-dev/hai-guardrails";const inputEngine = new GuardrailsEngine({ guards: [ injectionGuard({ roles: ["user"] }, { mode: "heuristic", threshold: 0.7 }), piiGuard({ selection: SelectionType.All }), secretGuard({ selection: SelectionType.All }), ],});export async function runInputGuardrails( content: string): Promise<{ passed: boolean; blockedBy: string[] }> { const results = await inputEngine.run([{ role: "user", content }]); const blockedBy: string[] = []; for (const guardResult of results.messagesWithGuardResult) { for (const msg of guardResult.messages) { if (!msg.passed) { blockedBy.push(guardResult.guardName); } } } return { passed: blockedBy.length === 0, blockedBy };}export async function runOutputGuardrails( content: string): Promise<{ passed: boolean; blockedBy: string[] }> { const results = await inputEngine.run([{ role: "assistant", content }]); const blockedBy: string[] = []; for (const guardResult of results.messagesWithGuardResult) { for (const msg of guardResult.messages) { if (!msg.passed) { blockedBy.push(guardResult.guardName); } } } return { passed: blockedBy.length === 0, blockedBy };}
Create src/lib/langfuse.ts to trace every LLM call. The module lazily connects to Langfuse and provides createTrace and createSpan. If Langfuse credentials are missing, it safely degrades to no-op stubs so the app still works offline.
Expected output: When LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set in the environment, the createTrace call returns a real Langfuse trace that you can inspect in the Langfuse dashboard. When they’re absent, all operations silently no-op.
Step 10: Create the in-memory observability store
Create src/observability/dashboard.ts to record workflow spans in memory. This is the data source for the Next.js dashboard and also logs to the mesh observability logger.
Expected output: After running a workflow, getSpans() returns an array of AgentSpan objects with timing, status, and confidence data for each step.
Step 11: Create the LangGraph workflow
This is the heart of the recipe. Create src/mesh/workflow.ts to define a LangGraph state graph with five nodes — writer, reviewer, confidence gate, scheduler, and revision — connected by edges and a conditional branch.
ts
import { StateGraph, Annotation } from "@langchain/langgraph";import type { Campaign, WorkflowState } from "../types/campaign.js";import type { ConfidenceDecision } from "@reaatech/agent-mesh";import { generateContent } from "../lib/vertex.js";import { checkBrandAlignment } from "./confidence.js";import { createTrace, createSpan } from "../lib/langfuse.js";import { runInputGuardrails, runOutputGuardrails } from "../guardrails/index.js";import { recordSpan } from "../observability/dashboard.js";const CampaignAnnotation = Annotation.Root({ campaign: Annotation<Campaign>,
Expected output: The graph starts at the writer node, runs input guardrails, then generates content. The reviewer scores it. The confidence_gate checks the score against the 0.85 threshold — if passing, scheduler runs output guardrails and simulates publishing; if failing, revision clears the output and loops back to writer.
Step 12: Create the campaign store
Create src/store/campaign-store.ts using zustand to hold campaigns in memory. It includes three demo campaigns so the dashboard has data to show from the start.
ts
import { create } from "zustand";import type { Campaign, CampaignInput } from "../types/campaign.js";interface CampaignStore { campaigns: Campaign[]; workflowRunning: boolean; addCampaign: (input: CampaignInput) => Campaign; updateCampaign: (id: string, patch: Partial<Campaign>) => void; removeCampaign: (id: string) => void; getCampaign: (id: string) => Campaign | undefined; setWorkflowRunning: (v: boolean) => void;}const demos: Campaign[] = [ { id: crypto.randomUUID(), title: "LinkedIn Product Launch", content: "Excited to announce our new AI-powered marketing platform that helps SMBs create campaigns in minutes!", channel: "linkedin", status: "draft", confidenceScore: 0, brandAlignment: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, { id: crypto.randomUUID(), title: "Blog: AI Marketing Trends", content: "In this post we explore the top AI marketing trends for 2025 including personalization, predictive analytics, and automated content generation.", channel: "blog", status: "draft", confidenceScore: 0, brandAlignment: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, { id: crypto.randomUUID(), title: "Twitter Thread on Automation", content: "1/ Marketing automation is changing the game for SMBs. Here's what you need to know about the latest tools and strategies in 2025. 🧵", channel: "twitter", status: "draft", confidenceScore: 0, brandAlignment: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), },];export const useCampaignStore = create<CampaignStore>((set, get) => ({ campaigns: demos, workflowRunning: false, addCampaign: (input: CampaignInput) => { const campaign: Campaign = { id: crypto.randomUUID(), title: input.title, content: input.content, channel: input.channel, status: "draft", confidenceScore: 0, brandAlignment: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; set((state) => ({ campaigns: [...state.campaigns, campaign] })); return campaign; }, updateCampaign: (id: string, patch: Partial<Campaign>) => { set((state) => ({ campaigns: state.campaigns.map((c) => c.id === id ? { ...c, ...patch, updatedAt: new Date().toISOString() } : c ), })); }, removeCampaign: (id: string) => { set((state) => ({ campaigns: state.campaigns.filter((c) => c.id !== id) })); }, getCampaign: (id: string) => { return get().campaigns.find((c) => c.id === id); }, setWorkflowRunning: (v: boolean) => { set({ workflowRunning: v }); },}));
Expected output: The store is populated with three demo campaigns. You can add, update, get, and remove campaigns through the store’s API.
Step 13: Build the API routes
Create the four Next.js App Router route handlers under app/api/.
Campaigns list and create — app/api/campaigns/route.ts:
import { NextRequest, NextResponse } from "next/server";import { getSpans } from "../../../../src/observability/dashboard.js";export function GET(req: NextRequest) { const campaignId = req.nextUrl.searchParams.get("campaignId") ?? undefined; return NextResponse.json(getSpans(campaignId));}
Expected output: With next dev running, GET /api/campaigns returns the demo campaigns array. POST /api/campaigns with a valid body creates a new campaign and returns 201. GET /api/campaigns/:id returns a single campaign or 404. POST /api/campaigns/:id/run starts the workflow and returns { workflowId, status: "started" }. GET /api/observability/spans returns the span history.
Step 14: Build the campaign dashboard
Replace the default app/page.tsx with the marketing dashboard. This client component shows all campaigns, lets you create new ones, run the workflow, and view the real-time workflow timeline.
Expected output: The dashboard renders with a three-column demo campaign list, a “New Campaign” form on the left, and an empty Workflow Timeline at the bottom. Clicking “Run” on a campaign triggers the agent mesh workflow.
Step 15: Configure instrumentation
Enable Next.js instrumentation to run the mesh registry and OpenTelemetry initialization at server startup. First, add the configuration flag to next.config.ts:
Then create src/instrumentation.ts — this file is special in Next.js; the framework calls its register() export at startup. Use dynamic imports to avoid loading Node-only modules in the Edge runtime:
Expected output: When the server starts, you’ll see the mesh registry initialization logs. Without experimental.instrumentationHook: true, the register() function is never called and the registry stays uninitialized.
Step 16: Write and run the tests
Create tests/mesh/workflow.test.ts to verify the complete LangGraph workflow. The tests mock the Vertex AI calls and guardrails to test the graph logic without hitting external APIs.
ts
import { describe, it, expect, vi, beforeEach } from "vitest";vi.mock("@langchain/langgraph", () => { const nodes = new Map<string, (state: Record<string, unknown>) => Partial<Record<string, unknown>>>(); const edges = new Map<string, string>(); const conditionalEdges = new Map<string, { routingFn: (state: Record<string, unknown
Run the tests:
terminal
pnpm test
Expected output: All 7 tests pass. The happy path publishes, the low-score path loops through revision, empty output triggers errors, output guardrails block content, input guardrails block content, Vertex API errors are caught, and scheduler errors are caught.
Next steps
Add a real channel adapter — Replace the simulated Published to ${channel}: ... string with an actual LinkedIn or Twitter API call using @langchain/community tools.
Deploy with LangGraph Cloud — The StateGraph workflow is already structured for deployment; add a langgraph.json config file and push to LangGraph Cloud for managed execution.
Extend the observability panel — Add filter controls, date range selectors, and per-agent latency charts using the span data from GET /api/observability/spans.
Add human-in-the-loop approval — Replace the auto-threshold gate with a Slack or email notification asking a human to approve borderline-score campaigns.
const prompt = `Write a ${state.campaign.channel} marketing post for the following campaign:\n\nTitle: ${state.campaign.title}\n\nContent: ${state.campaign.content}`;
const generated = await generateContent(prompt, "You are a professional content marketing writer.");
const prompt = `Score this marketing copy for brand alignment on a scale of 0 to 1:\n\n${state.writerOutput || "(empty)"}\n\nReturn only a number between 0 and 1.`;
const generated = await generateContent(prompt, "You are a brand alignment reviewer.");