DEV Community

Shubham Raghav
Shubham Raghav

Posted on

How I Orchestrate 7 AI Agents in a Single Next.js App (With Code)

Most "AI agent" posts are theory. This one has code.

I built Cresva, an AI marketing platform with 7 specialized agents that pull data from Meta, Google Ads, GA4, and Shopify, analyze it, and deliver insights autonomously. One Next.js app. One developer. No microservices.

Here's how the orchestration actually works.

The Problem With Single-Agent Systems

If you've built an AI wrapper, you've hit this wall: one LLM call can't do everything well. Ask GPT-4 to pull data AND analyze it AND generate strategy AND format a report in one prompt? You get mediocre output across the board.

The fix: specialized agents with typed contracts between them.

Each agent in Cresva has one job:

type Agent = {
  id: string;
  role: 'data_engineer' | 'performance_analyst' | 'media_strategist' 
       | 'attribution_analyst' | 'creative_strategist' 
       | 'marketing_ops' | 'account_manager';
  execute: (input: AgentInput) => Promise<AgentOutput>;
  validate: (output: AgentOutput) => ValidationResult;
};
Enter fullscreen mode Exit fullscreen mode

Seven agents, seven roles:

  • Dana (Data Engineer): Pulls and unifies data across platforms
  • Felix (Performance Analyst): Forecasts revenue, 91% accuracy, 90 days out
  • Sam (Media Strategist): Simulates 50+ budget scenarios before you spend
  • Parker (Attribution Analyst): Shows true incremental impact, strips platform bias
  • Olivia (Creative Strategist): Predicts creative fatigue using GPT-4 Vision
  • Dex (Marketing Ops): Auto-delivers reports to Slack and Email
  • Maya (Account Manager): Maintains memory across 800+ conversations

The Orchestrator Pattern

The core idea: build a task graph, topologically sort it, execute in order with parallel branches where possible.

interface TaskNode {
  id: string;
  agent: Agent;
  inputs: Record<string, any>;
  dependsOn: string[];  // IDs of upstream nodes
  canSkip: boolean;     // If true, failure won't kill the run
}

function buildTaskGraph(goal: string, context: BrandContext): TaskNode[] {
  // The orchestrator decides which agents to invoke
  // based on the goal and available data

  if (goal === 'weekly_review') {
    return [
      { id: 'pull_data', agent: dana, dependsOn: [], canSkip: false },
      { id: 'forecast', agent: felix, dependsOn: ['pull_data'], canSkip: true },
      { id: 'attribution', agent: parker, dependsOn: ['pull_data'], canSkip: true },
      { id: 'creative_check', agent: olivia, dependsOn: ['pull_data'], canSkip: true },
      { id: 'strategy', agent: sam, dependsOn: ['forecast', 'attribution'], canSkip: false },
      { id: 'deliver', agent: dex, dependsOn: ['strategy', 'creative_check'], canSkip: false },
    ];
  }
  // ... other goal types
}
Enter fullscreen mode Exit fullscreen mode

The execution loop:

async function executeRun(runId: string, graph: TaskNode[]) {
  const sorted = topoSort(graph);
  const results: Map<string, AgentOutput> = new Map();

  for (const node of sorted) {
    try {
      // Gather outputs from upstream dependencies
      const inputs = node.dependsOn.reduce((acc, depId) => {
        acc[depId] = results.get(depId);
        return acc;
      }, {} as Record<string, any>);

      await recordStepStart(runId, node.id);

      const output = await node.agent.execute({
        runId,
        ...node.inputs,
        ...inputs,
      });

      // Critic validation before accepting output
      const validation = node.agent.validate(output);
      if (!validation.pass) {
        await handleValidationFail(runId, node, validation);
        if (!node.canSkip) throw new Error(validation.reason);
        continue;
      }

      results.set(node.id, output);
      await saveStepOutputs(runId, node.id, output);

    } catch (e) {
      await handleFail(runId, node, e);
      if (!node.canSkip) throw e;
    }
  }

  await composeAndDeliver(runId, results);
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy. No LangChain. No CrewAI. Just TypeScript, a DAG, and typed inputs/outputs.

Typed Contracts: The Key to Multi-Agent Reliability

This is the part most tutorials skip. When Agent A passes data to Agent B, what exactly does that data look like?

Every agent in Cresva has a strict input/output contract:

// Dana (Data Engineer) output contract
interface DataPullOutput {
  table: {
    rows: Row[];
    columns: ColumnDef[];
  };
  schema: {
    source: 'meta' | 'google' | 'shopify' | 'ga4';
    grain: 'hourly' | 'daily' | 'weekly';
    dateRange: { start: string; end: string };
  };
  freshness: {
    pulledAt: string;       // ISO timestamp
    cacheHit: boolean;
    staleness: number;      // seconds since last API call
  };
  provenance: {
    apiVersion: string;
    endpoint: string;
    accountId: string;
  };
}

// Felix (Performance Analyst) expects exactly this shape
// No ambiguity. No "parse this unstructured text."
Enter fullscreen mode Exit fullscreen mode

Why this matters: when you pass unstructured text between agents, hallucinations leak between steps. Agent A hallucinates a number, Agent B builds a strategy on it, Agent C delivers it to a client. Nobody catches the bad data.

Typed contracts + validation at every step = hallucination firewall.

The Critic Layer

Before any output reaches the user, a Critic validates it against business rules:

interface MetricRule {
  metric: string;
  min?: number;
  max?: number;
  type: 'percentage' | 'currency' | 'count' | 'ratio';
}

const METRIC_RULES: MetricRule[] = [
  { metric: 'ctr', min: 0, max: 100, type: 'percentage' },
  { metric: 'roas', min: 0, max: 50, type: 'ratio' },
  { metric: 'cpc', min: 0, max: 500, type: 'currency' },
  { metric: 'spend', min: 0, type: 'currency' },
  { metric: 'conversions', min: 0, type: 'count' },
];

function criticCheck(output: AgentOutput): ValidationResult {
  const violations: string[] = [];

  for (const [key, value] of Object.entries(output.metrics || {})) {
    const rule = METRIC_RULES.find(r => r.metric === key);
    if (!rule) continue;

    if (rule.min !== undefined && value < rule.min) {
      violations.push(`${key} = ${value} is below minimum ${rule.min}`);
    }
    if (rule.max !== undefined && value > rule.max) {
      violations.push(`${key} = ${value} exceeds maximum ${rule.max}`);
    }
  }

  return {
    pass: violations.length === 0,
    violations,
    reason: violations.join('; '),
  };
}
Enter fullscreen mode Exit fullscreen mode

CTR over 100%? Blocked. Negative spend? Blocked. ROAS of 847? Probably hallucinated. Blocked.

This catches more bad outputs than you'd expect. LLMs are surprisingly good at generating plausible-but-impossible numbers.

Provenance: Every Number Has a Receipt

Marketing people are skeptical of AI-generated numbers. They should be.

Every metric in Cresva carries provenance metadata:

interface MetricWithProvenance {
  value: number;
  provenance: {
    source: string;         // "Meta Marketing API v19.0"
    pulledAt: string;       // "2026-02-28T09:47:12Z"
    computation: string;    // "revenue / spend"
    freshness: string;      // "3 minutes ago"
    cacheStatus: 'fresh' | 'stale' | 'miss';
  };
}
Enter fullscreen mode Exit fullscreen mode

When a client asks "where did this 3.2x ROAS come from?", the system answers: "Meta Marketing API, pulled 3 minutes ago, computed as revenue divided by spend, from campaign IDs [X, Y, Z]."

Trust is the product. Not insights. Trust.

Caching Strategy: Freshness By Data Grain

Not all data needs to be real-time. A caching layer that understands data granularity saves API calls and money:

const CACHE_TTL: Record<string, number> = {
  'hourly':  5 * 60,          // 5 minutes
  'daily':   4 * 60 * 60,     // 4 hours  
  'weekly':  24 * 60 * 60,    // 24 hours
  'monthly': 7 * 24 * 60 * 60 // 7 days
};

async function fetchWithCache(
  key: string, 
  grain: string, 
  fetcher: () => Promise<any>
) {
  const cached = await redis.get(key);

  if (cached) {
    const parsed = JSON.parse(cached);
    const age = Date.now() - parsed.pulledAt;

    if (age < CACHE_TTL[grain] * 1000) {
      return { ...parsed, cacheHit: true };
    }
  }

  const fresh = await fetcher();
  await redis.setex(key, CACHE_TTL[grain], JSON.stringify({
    ...fresh,
    pulledAt: Date.now()
  }));

  return { ...fresh, cacheHit: false };
}
Enter fullscreen mode Exit fullscreen mode

Hourly campaign data? Cache for 5 minutes. Monthly revenue trends? Cache for a week. The source planner decides automatically.

Agent Memory: The Compound Learning Effect

Most AI apps are stateless. Every conversation starts from zero. That's fine for a chatbot. Terrible for a marketing analyst.

Maya (Account Manager) maintains persistent memory per brand:

interface AgentMemory {
  brandId: string;
  key: string;            // "anomaly:cpm_spike:meta"
  value: any;
  createdAt: string;
  lastAccessedAt: string;
  accessCount: number;
  agentId: string;        // Which agent created this memory
}

// When Felix detects an anomaly
await saveMemory({
  brandId: 'brand_123',
  key: 'anomaly:cpm_spike:meta',
  value: {
    date: '2026-02-25',
    metric: 'cpm',
    change: '+42%',
    cause: 'audience_saturation',
    recommendation: 'refresh_lookalikes'
  },
  agentId: 'felix'
});

// Next week, if the same pattern appears
const priorAnomaly = await getMemory('brand_123', 'anomaly:cpm_spike:meta');
if (priorAnomaly) {
  // "This is a recurring pattern, first detected on Feb 25th"
  // instead of treating it as new
}
Enter fullscreen mode Exit fullscreen mode

The 10th analysis is meaningfully better than the 1st. The agents know your brand's history, patterns, and what worked before. That's the moat.

Creative Fatigue Prediction With GPT-4 Vision

Olivia (Creative Strategist) uses GPT-4 Vision to analyze ad creatives and predict when they'll fatigue:

const response = await openai.chat.completions.create({
  model: 'gpt-4o',
  temperature: 0.2,  // Low temp for consistency
  response_format: { type: 'json_object' },
  messages: [
    {
      role: 'system',
      content: `Analyze this ad creative. Return JSON with:
        - fatigue_prediction: { low_days, mid_days, high_days }
        - creative_dna: { uniqueness, scroll_stop, emotional_resonance, 
                         message_clarity, brand_recall } (each 0-100)
        - fatigue_signals: string[]
        - recommendations: string[]
        Use industry benchmarks: ecommerce avg 14-24 days, 
        TikTok fatigues 40% faster than Meta.`
    },
    {
      role: 'user',
      content: [
        { type: 'image_url', image_url: { url: imageBase64 } },
        { type: 'text', text: `Platform: ${platform}, Industry: ${industry}` }
      ]
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

Key findings from analyzing thousands of ads:

  • Ads with human faces last ~3 days longer than graphic-only
  • TikTok creatives fatigue 40% faster than Meta
  • Template-style creatives saturate at 2x the rate of custom ones
  • Copy matters more than visuals for fatigue resistance

We ship this as a free tool at cresva.ai/tools/creative-fatigue. 4,000+ analyses per month.

The Stack

For anyone building something similar:

  • Framework: Next.js 14 (App Router), TypeScript end-to-end
  • Database: PostgreSQL via Prisma (AgentRun, AgentStep, AgentMemory tables)
  • LLM: GPT-4o for vision + reasoning, Claude for strategy synthesis
  • Cache: Redis with grain-based TTLs
  • Integrations: Meta Marketing API, Google Ads API, GA4, Shopify, Slack
  • Auth: OAuth with encrypted token storage and rotation
  • Deployment: Vercel

No LangChain. No vector databases. No microservices. Just clean TypeScript with typed contracts.

What I'd Do Differently

Skip LangChain from day one. I evaluated it early and decided against it. Right call. The abstraction adds complexity without value when you have 7 agents with specific, well-defined contracts. Plain TypeScript is easier to debug, test, and reason about.

Start with Slack delivery, not dashboards. I spent too long building beautiful UI when the highest-value output is a Slack message at 8am saying "your Meta CPA spiked 23% yesterday, here's why, here's what to do." Agents don't need pretty interfaces to prove value.

Go deep on one platform first. Meta alone has enough API complexity for months. I wired up Google and TikTok too early. Should have nailed Meta integration first, then expanded.

Try It

The free tools are live at cresva.ai/tools. 20 tools, each powered by the agent system underneath.

If you're building multi-agent systems, I'm happy to discuss architecture decisions in the comments. I've made enough mistakes to save you a few.


I'm Shubham Raghav, building Cresva AI. Follow the build on X or connect on LinkedIn.

Top comments (0)