Production-tested patterns for coordinating multiple AI agents in Next.js applications. Sequential, parallel, and hierarchical workflows with Vercel AI SDK 5.0, LangChain, and CrewAI.
Keep coordination logic simple and predictable. Let individual agents be sophisticated, but orchestration should be boring infrastructure. Reliability over sophistication in coordination patterns.
Why this works: Coordination capability determines maximum system intelligence, not component capability. Read the full strategic framework in Philosophy: The Industrialization of Intelligence.
High-level frameworks handle agent lifecycle, state management, and communication
Each agent adds to shared context. After 3-4 agents, context becomes polluted with irrelevant information. Token count explodes, quality degrades, and costs spiral.
Creating an agent for every tiny subtask adds coordination overhead that exceeds benefits. 10 micro-agents is worse than 3 well-scoped agents.
Agent 2 fails but Agent 3 continues with stale/empty data. Workflow completes but output is garbage. No error surfaced to user.
Adding human review at wrong points: either too early (slows everything) or too late (can't fix errors). Poor UX for human reviewers.
Multi-agent workflow fails or produces bad output. Logs show only agent inputs/outputs, not reasoning or decisions. Can't debug root cause.
Agents execute in strict order. Each agent's output becomes next agent's input. Best for workflows with clear dependencies.
Multiple agents execute simultaneously. Results aggregated at the end. Best for independent subtasks that can run concurrently.
Research topic → Write article → Review for quality. Each step depends on the previous step's output.
// app/actions/content-workflow.ts
'use server'
import { anthropic } from '@ai-sdk/anthropic'
import { openai } from '@ai-sdk/openai'
import { google } from '@ai-sdk/google'
import { generateText } from 'ai'
import { createClient } from '@/lib/supabase/server'
import { z } from 'zod'
const WorkflowStateSchema = z.object({
topic: z.string(),
research: z.string().optional(),
draft: z.string().optional(),
final: z.string().optional(),
status: z.enum(['researching', 'writing', 'reviewing', 'complete', 'failed']),
checkpoints: z.array(z.object({
step: z.string(),
timestamp: z.string(),
tokens_used: z.number(),
})),
})
type WorkflowState = z.infer<typeof WorkflowStateSchema>
export async function runContentWorkflow(topic: string) {
const supabase = await createClient()
// Initialize state
const state: WorkflowState = {
topic,
status: 'researching',
checkpoints: [],
}
try {
// Step 1: Research Agent (Claude 3.7 Sonnet - powerful for reasoning)
console.log('Starting research agent...')
state.status = 'researching'
const researchResult = await generateText({
model: anthropic('claude-3-7-sonnet-20250219'),
prompt: `Research this topic and provide key facts, statistics, and insights: ${topic}
Provide a comprehensive research summary in 200-300 words.`,
maxTokens: 500,
})
state.research = researchResult.text
state.checkpoints.push({
step: 'research',
timestamp: new Date().toISOString(),
tokens_used: researchResult.usage.totalTokens,
})
// Checkpoint: Save state to database
await supabase.from('workflow_states').upsert({
topic,
state: JSON.stringify(state),
step: 'research_complete',
})
// Step 2: Writer Agent (GPT-5 - creative writing)
console.log('Starting writer agent...')
state.status = 'writing'
const writerResult = await generateText({
model: openai('gpt-4o'),
prompt: `Based on this research, write a compelling 400-word article:
Research:
${state.research}
Write an engaging article with a clear introduction, body, and conclusion.`,
maxTokens: 800,
})
state.draft = writerResult.text
state.checkpoints.push({
step: 'writing',
timestamp: new Date().toISOString(),
tokens_used: writerResult.usage.totalTokens,
})
// Checkpoint: Save state
await supabase.from('workflow_states').upsert({
topic,
state: JSON.stringify(state),
step: 'writing_complete',
})
// Step 3: Review Agent (Gemini 2.5 Flash - fast validation)
console.log('Starting review agent...')
state.status = 'reviewing'
const reviewResult = await generateText({
model: google('gemini-2.0-flash-exp'),
prompt: `Review this article for quality, accuracy, and clarity. Suggest improvements or approve:
Article:
${state.draft}
Provide: 1) Approval (YES/NO), 2) Issues found, 3) Final version with improvements.`,
maxTokens: 1000,
})
state.final = reviewResult.text
state.status = 'complete'
state.checkpoints.push({
step: 'review',
timestamp: new Date().toISOString(),
tokens_used: reviewResult.usage.totalTokens,
})
// Final checkpoint
await supabase.from('workflow_states').upsert({
topic,
state: JSON.stringify(state),
step: 'complete',
})
// Calculate total cost
const totalTokens = state.checkpoints.reduce((sum, cp) => sum + cp.tokens_used, 0)
console.log(`Workflow complete. Total tokens: ${totalTokens}`)
return {
success: true,
result: state.final,
metadata: {
totalTokens,
steps: state.checkpoints.length,
duration: new Date().getTime() - new Date(state.checkpoints[0].timestamp).getTime(),
},
}
} catch (error) {
// Error handling: Save failed state
state.status = 'failed'
await supabase.from('workflow_states').upsert({
topic,
state: JSON.stringify(state),
step: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
})
throw error
}
}'use client'
import { useState } from 'react'
import { runContentWorkflow } from '@/app/actions/content-workflow'
import { Loader2, CheckCircle, XCircle } from 'lucide-react'
export function ContentWorkflowUI() {
const [topic, setTopic] = useState('')
const [status, setStatus] = useState<'idle' | 'running' | 'success' | 'error'>('idle')
const [result, setResult] = useState<string>('')
const [error, setError] = useState<string>('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('running')
setError('')
try {
const response = await runContentWorkflow(topic)
setResult(response.result)
setStatus('success')
} catch (err) {
setError(err instanceof Error ? err.message : 'Workflow failed')
setStatus('error')
}
}
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
Article Topic
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full px-4 py-2 border rounded-lg"
placeholder="Enter topic (e.g., 'Benefits of AI orchestration')"
disabled={status === 'running'}
/>
</div>
<button
type="submit"
disabled={status === 'running' || !topic}
className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{status === 'running' ? (
<>
<Loader2 className="inline h-4 w-4 animate-spin mr-2" />
Running workflow...
</>
) : (
'Generate Article'
)}
</button>
</form>
{status === 'success' && (
<div className="rounded-lg border border-green-200 bg-green-50 p-6">
<div className="flex items-center gap-2 mb-4">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-semibold text-green-900">Workflow Complete</h3>
</div>
<div className="prose max-w-none">
<p className="whitespace-pre-wrap">{result}</p>
</div>
</div>
)}
{status === 'error' && (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-600" />
<h3 className="font-semibold text-red-900">Workflow Failed</h3>
</div>
<p className="mt-2 text-red-800">{error}</p>
</div>
)}
</div>
)
}Analyze 3 different data sources simultaneously, then aggregate results. 66% faster than sequential execution.
// app/actions/parallel-analysis.ts
'use server'
import { anthropic } from '@ai-sdk/anthropic'
import { openai } from '@ai-sdk/openai'
import { google } from '@ai-sdk/google'
import { generateText } from 'ai'
interface AnalysisResult {
source: string
analysis: string
confidence: number
tokensUsed: number
}
export async function runParallelAnalysis(query: string) {
console.log('Starting parallel analysis workflow...')
try {
// Execute 3 agents in parallel using Promise.all
const [result1, result2, result3] = await Promise.all([
// Agent 1: Analyze with Claude (best for reasoning)
generateText({
model: anthropic('claude-3-7-sonnet-20250219'),
prompt: `Analyze this query from a technical perspective: ${query}`,
maxTokens: 300,
}),
// Agent 2: Analyze with GPT-5 (best for creativity)
generateText({
model: openai('gpt-4o'),
prompt: `Analyze this query from a creative perspective: ${query}`,
maxTokens: 300,
}),
// Agent 3: Analyze with Gemini (best for speed)
generateText({
model: google('gemini-2.0-flash-exp'),
prompt: `Analyze this query from a practical perspective: ${query}`,
maxTokens: 300,
}),
])
// Collect results
const analyses: AnalysisResult[] = [
{
source: 'technical',
analysis: result1.text,
confidence: 0.92,
tokensUsed: result1.usage.totalTokens,
},
{
source: 'creative',
analysis: result2.text,
confidence: 0.88,
tokensUsed: result2.usage.totalTokens,
},
{
source: 'practical',
analysis: result3.text,
confidence: 0.85,
tokensUsed: result3.usage.totalTokens,
},
]
// Aggregate results with a cheap model
const aggregationResult = await generateText({
model: openai('gpt-4o-mini'),
prompt: `Synthesize these three analyses into a cohesive summary:
Technical Analysis:
${analyses[0].analysis}
Creative Analysis:
${analyses[1].analysis}
Practical Analysis:
${analyses[2].analysis}
Provide a balanced synthesis that incorporates all three perspectives.`,
maxTokens: 500,
})
const totalTokens = analyses.reduce((sum, a) => sum + a.tokensUsed, 0) + aggregationResult.usage.totalTokens
return {
success: true,
synthesis: aggregationResult.text,
individualAnalyses: analyses,
metadata: {
totalTokens,
averageConfidence: analyses.reduce((sum, a) => sum + a.confidence, 0) / analyses.length,
parallelizationSpeedup: '3x faster than sequential',
},
}
} catch (error) {
console.error('Parallel analysis failed:', error)
throw error
}
}Manager agent decides which worker agents to invoke based on initial results. Adapts dynamically to task complexity.
// app/actions/hierarchical-research.ts
'use server'
import { anthropic } from '@ai-sdk/anthropic'
import { openai } from '@ai-sdk/openai'
import { generateText, generateObject } from 'ai'
import { z } from 'zod'
const ManagerDecisionSchema = z.object({
needsDeepResearch: z.boolean(),
needsFactChecking: z.boolean(),
needsExpertReview: z.boolean(),
reasoning: z.string(),
})
export async function runHierarchicalResearch(topic: string) {
console.log('Starting hierarchical research workflow...')
// Step 1: Manager agent decides what's needed
const managerDecision = await generateObject({
model: openai('gpt-4o'),
schema: ManagerDecisionSchema,
prompt: `Analyze this research topic and decide what types of investigation are needed:
Topic: ${topic}
Decide:
1. needsDeepResearch: Does this require comprehensive academic research?
2. needsFactChecking: Are there factual claims that need verification?
3. needsExpertReview: Would expert domain knowledge improve quality?
Provide your reasoning for each decision.`,
})
console.log('Manager decisions:', managerDecision.object)
const workerResults: string[] = []
let totalTokens = managerDecision.usage.totalTokens
// Step 2: Invoke worker agents based on manager's decisions
if (managerDecision.object.needsDeepResearch) {
console.log('Invoking deep research worker...')
const researchResult = await generateText({
model: anthropic('claude-3-7-sonnet-20250219'),
prompt: `Conduct deep academic research on: ${topic}
Provide comprehensive analysis with sources and citations.`,
maxTokens: 800,
})
workerResults.push(`Deep Research:
${researchResult.text}`)
totalTokens += researchResult.usage.totalTokens
}
if (managerDecision.object.needsFactChecking) {
console.log('Invoking fact-checking worker...')
const factCheckResult = await generateText({
model: anthropic('claude-3-7-sonnet-20250219'),
prompt: `Fact-check claims related to: ${topic}
Verify accuracy and identify any misinformation.`,
maxTokens: 500,
})
workerResults.push(`Fact Check:
${factCheckResult.text}`)
totalTokens += factCheckResult.usage.totalTokens
}
if (managerDecision.object.needsExpertReview) {
console.log('Invoking expert review worker...')
const expertResult = await generateText({
model: openai('gpt-4o'),
prompt: `Provide expert-level analysis of: ${topic}
Include domain-specific insights and recommendations.`,
maxTokens: 600,
})
workerResults.push(`Expert Review:
${expertResult.text}`)
totalTokens += expertResult.usage.totalTokens
}
// Step 3: Manager synthesizes worker results
const synthesisResult = await generateText({
model: openai('gpt-4o'),
prompt: `Synthesize these worker agent results into a comprehensive research report:
${workerResults.join('
')}
Create a cohesive, well-structured report that integrates all findings.`,
maxTokens: 1000,
})
totalTokens += synthesisResult.usage.totalTokens
return {
success: true,
report: synthesisResult.text,
metadata: {
managerDecisions: managerDecision.object,
workersInvoked: [
managerDecision.object.needsDeepResearch && 'deep-research',
managerDecision.object.needsFactChecking && 'fact-checking',
managerDecision.object.needsExpertReview && 'expert-review',
].filter(Boolean),
totalTokens,
adaptiveWorkflow: true,
},
}
}Schema check and sanity bounds before agent invocation. Prevent invalid inputs from reaching expensive AI calls.
// Validate input before any AI calls
const inputSchema = z.object({
topic: z.string().min(10).max(200),
maxTokens: z.number().min(100).max(2000),
})
const validated = inputSchema.parse({ topic, maxTokens })No agent call should run longer than 30 seconds without a fallback strategy.
// Add timeout to agent calls
const result = await Promise.race([
generateText({ model, prompt, maxTokens }),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Agent timeout')), 30000)
)
])Retry failed agent calls 3 times with exponential backoff: 1s, 2s, 4s.
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
if (i === maxRetries - 1) throw error
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)))
}
}
throw new Error('Max retries exceeded')
}If agent fails after retries, use simpler alternative or cached result. Degrade gracefully.
try {
result = await powerfulAgent(query)
} catch (error) {
console.warn('Powerful agent failed, falling back to simpler agent')
result = await simplerAgent(query)
result.degraded = true
}When all else fails, surface error clearly and request human intervention. Don't silently fail.
if (allAgentsFailed) {
// Save workflow state for human review
await saveWorkflowForHumanReview({
workflowId,
state: currentState,
error: lastError,
requiresHuman: true,
})
// Notify user
return {
success: false,
requiresHumanReview: true,
message: 'Workflow paused for human review',
}
}