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;
};
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
}
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);
}
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."
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('; '),
};
}
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';
};
}
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 };
}
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
}
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}` }
]
}
]
});
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)