Why Architecture Matters More Than Model Choice

Most discussions about AI agents focus on which model to use. The architecture — how your agent reasons, plans, and acts — has a larger impact on reliability than the model powering it.

A well-architected agent running on Claude Haiku 4.5 will outperform a poorly architected agent running on Claude Opus 4.6 on most real-world tasks. The architecture determines how the agent handles errors, recovers from dead ends, coordinates work, and knows when to stop.

In 2026, three patterns dominate production agent systems:

  1. ReAct — reason and act in a tight loop, one step at a time
  2. Plan-and-Execute — plan the full task upfront, then execute each step
  3. Multi-Agent — multiple specialized agents coordinated by an orchestrator

This guide explains how each works, when to use each, and gives working TypeScript code for all three.


Pattern 1: ReAct

How it works

ReAct (Reasoning + Acting) is the simplest and most widely used agent pattern. The agent alternates between two steps in a loop:

  1. Reason — think about the current state and what to do next
  2. Act — call a tool or take an action
  3. Observe the result and repeat until the task is complete

The loop continues until the agent decides the task is done or hits a maximum iteration limit.

Task: Find the current price of NVIDIA stock and calculate how much $10,000 invested 1 year ago would be worth today.

Thought: I need to find the current NVIDIA stock price and the price 1 year ago.
Action: search_web(\"NVIDIA stock price today\")
Observation: NVIDIA (NVDA) current price: $875.40

Thought: Now I need the price 1 year ago.
Action: search_web(\"NVIDIA stock price March 2025\")
Observation: NVIDIA (NVDA) price March 2026: ~$628.00

Thought: I have both prices. Now I can calculate the return.
Action: calculate({ formula: \"(875.40 / 628.00 - 1) * 100\" })
Observation: 39.39

Thought: $10,000 invested would now be worth $10,000 * (875.40 / 628.00) = $13,939.
Final Answer: $10,000 invested in NVIDIA one year ago would be worth approximately $13,939 today, a 39.4% return.

When to use ReAct

  • Tasks where the next step depends on the result of the previous step
  • Exploratory tasks where you cannot know the full plan upfront
  • Shorter tasks (under ~10 steps) where planning overhead is not worth it
  • Tasks with well-defined tools and clear completion criteria

When NOT to use ReAct

  • Long tasks with many sequential steps — the agent can lose track of the overall goal
  • Tasks that require parallel execution — ReAct is strictly sequential
  • Tasks where early mistakes are hard to recover from

ReAct implementation

import Anthropic from \"@anthropic-ai/sdk\";

const client = new Anthropic();

interface Tool {
  name: string;
  description: string;
  execute: (input: Record<string, unknown>) => Promise<string>;
}

const tools: Tool[] = [
  {
    name: \"read_file\",
    description: \"Read the contents of a file at the given path\",
    execute: async ({ path }: Record<string, unknown>) => {
      const fs = await import(\"fs/promises\");
      return fs.readFile(path as string, \"utf-8\");
    },
  },
  {
    name: \"write_file\",
    description: \"Write content to a file at the given path\",
    execute: async ({ path, content }: Record<string, unknown>) => {
      const fs = await import(\"fs/promises\");
      await fs.writeFile(path as string, content as string, \"utf-8\");
      return `File written successfully to ${path}`;
    },
  },
  {
    name: \"run_command\",
    description: \"Run a shell command and return its output\",
    execute: async ({ command }: Record<string, unknown>) => {
      const { exec } = await import(\"child_process\");
      const { promisify } = await import(\"util\");
      const execAsync = promisify(exec);
      const { stdout, stderr } = await execAsync(command as string);
      return stdout || stderr;
    },
  },
];

async function reactAgent(task: string, maxIterations = 10): Promise<string> {
  const messages: Anthropic.MessageParam[] = [
    { role: \"user\", content: task }
  ];

  const toolDefinitions = tools.map((t) => ({
    name: t.name,
    description: t.description,
    input_schema: {
      type: \"object\" as const,
      properties: {
        path: { type: \"string\" },
        content: { type: \"string\" },
        command: { type: \"string\" },
      },
    },
  }));

  for (let i = 0; i < maxIterations; i++) {
    const response = await client.messages.create({
      model: \"claude-sonnet-4-20250514\",
      max_tokens: 4096,
      tools: toolDefinitions,
      messages,
    });

    // Add assistant response to history
    messages.push({ role: \"assistant\", content: response.content });

    // Task complete
    if (response.stop_reason === \"end_turn\") {
      const textBlock = response.content.find((b) => b.type === \"text\");
      return textBlock ? textBlock.text : \"Task completed.\";
    }

    // Execute tool calls
    if (response.stop_reason === \"tool_use\") {
      const toolResults: Anthropic.ToolResultBlockParam[] = [];

      for (const block of response.content) {
        if (block.type !== \"tool_use\") continue;

        const tool = tools.find((t) => t.name === block.name);
        if (!tool) {
          toolResults.push({
            type: \"tool_result\",
            tool_use_id: block.id,
            content: `Error: tool ${block.name} not found`,
          });
          continue;
        }

        try {
          const result = await tool.execute(
            block.input as Record<string, unknown>
          );
          toolResults.push({
            type: \"tool_result\",
            tool_use_id: block.id,
            content: result,
          });
        } catch (error) {
          toolResults.push({
            type: \"tool_result\",
            tool_use_id: block.id,
            content: `Error: ${error instanceof Error ? error.message : String(error)}`,
          });
        }
      }

      messages.push({ role: \"user\", content: toolResults });
    }
  }

  return \"Max iterations reached without completing the task.\";
}

// Usage
const result = await reactAgent(
  \"Read package.json and tell me which dependencies are outdated based on their version numbers\"
);
console.log(result);

Pattern 2: Plan-and-Execute

How it works

Plan-and-Execute separates thinking from doing into two distinct phases:

  1. Plan — the agent receives the task and produces a complete, ordered list of steps
  2. Execute — each step is executed sequentially (or in parallel where possible)
  3. Replan — if a step fails or produces unexpected results, the agent can replan the remaining steps
Task: Refactor the authentication module to use JWT instead of session cookies.

PLAN:
1. Read app/auth/session.ts to understand current implementation
2. Read all files that import from app/auth/session.ts
3. Read app/middleware.ts to understand how auth is applied
4. Write new app/auth/jwt.ts with JWT implementation
5. Update each file identified in step 2 to use the new JWT module
6. Update app/middleware.ts to use JWT validation
7. Run TypeScript compiler to check for type errors
8. Run test suite and fix any failures

EXECUTING STEP 1...
EXECUTING STEP 2...
...

When to use Plan-and-Execute

  • Long, multi-step tasks where having a plan upfront reduces errors
  • Tasks where you want to review the plan before execution begins
  • Tasks with clear, predictable steps that do not depend heavily on intermediate results
  • Workflows where human approval of the plan is required before proceeding

When NOT to use Plan-and-Execute

  • Exploratory tasks where the next step cannot be known until the previous result is seen
  • Short tasks where planning overhead exceeds execution time
  • Highly dynamic tasks where the plan will need constant revision

Plan-and-Execute implementation

import Anthropic from \"@anthropic-ai/sdk\";

const client = new Anthropic();

interface Step {
  id: number;
  description: string;
  status: \"pending\" | \"running\" | \"completed\" | \"failed\";
  result?: string;
}

async function createPlan(task: string): Promise<Step[]> {
  const response = await client.messages.create({
    model: \"claude-sonnet-4-20250514\",
    max_tokens: 2048,
    system: `You are a planning agent. Given a task, produce a numbered list of concrete, executable steps.
Respond with JSON only. Format: { \"steps\": [{ \"id\": 1, \"description\": \"...\" }] }`,
    messages: [{ role: \"user\", content: task }],
  });

  const text = response.content[0].type === \"text\" ? response.content[0].text : \"\";
  const clean = text.replace(/```json|```/g, \"\").trim();
  const parsed = JSON.parse(clean) as { steps: Omit<Step, \"status\">[] };

  return parsed.steps.map((s) => ({ ...s, status: \"pending\" }));
}

async function executeStep(
  step: Step,
  task: string,
  completedSteps: Step[]
): Promise<string> {
  const context = completedSteps
    .filter((s) => s.status === \"completed\")
    .map((s) => `Step ${s.id} (${s.description}): ${s.result}`)
    .join(\"\
\");

  const response = await client.messages.create({
    model: \"claude-sonnet-4-20250514\",
    max_tokens: 4096,
    system: `You are an execution agent. You execute one step of a larger plan.
Original task: ${task}
Completed steps so far:
${context || \"None yet\"}

Execute only the step you are given. Be specific and thorough.`,
    messages: [
      {
        role: \"user\",
        content: `Execute this step: ${step.description}`,
      },
    ],
  });

  return response.content[0].type === \"text\" ? response.content[0].text : \"\";
}

async function planAndExecute(task: string): Promise<void> {
  console.log(\"Creating plan...\");
  const steps = await createPlan(task);

  console.log(`\
Plan created with ${steps.length} steps:`);
  steps.forEach((s) => console.log(`  ${s.id}. ${s.description}`));
  console.log();

  for (const step of steps) {
    console.log(`Executing step ${step.id}: ${step.description}`);
    step.status = \"running\";

    try {
      step.result = await executeStep(step, task, steps);
      step.status = \"completed\";
      console.log(`  ✓ Completed\
`);
    } catch (error) {
      step.status = \"failed\";
      step.result = error instanceof Error ? error.message : String(error);
      console.log(`  ✗ Failed: ${step.result}\
`);

      // Replan remaining steps based on failure
      const remainingSteps = steps.filter((s) => s.status === \"pending\");
      if (remainingSteps.length > 0) {
        console.log(\"Replanning remaining steps...\");
        // In a full implementation, you would replan here
        break;
      }
    }
  }

  const completed = steps.filter((s) => s.status === \"completed\").length;
  console.log(`\
Completed ${completed}/${steps.length} steps.`);
}

// Usage
await planAndExecute(
  \"Audit the codebase for console.log statements left in production code and remove them\"
);

Pattern 3: Multi-Agent

How it works

Multi-Agent systems use multiple specialized agents coordinated by an orchestrator:

  1. Orchestrator — receives the task, breaks it into subtasks, assigns each to a specialist agent
  2. Specialist agents — each handles one type of work (research, coding, testing, writing)
  3. Aggregator — combines results from specialists into a final output
Task: Write a technical blog post about the new Nemotron 3 Super model.

Orchestrator assigns:
├── Research Agent → gather benchmark data, official announcements, community reactions
├── Outline Agent → create post structure based on research
├── Writing Agent → write each section based on outline + research
└── Editor Agent → review for accuracy, clarity, and SEO

All agents run, results aggregated → final blog post

When to use Multi-Agent

  • Complex tasks that benefit from specialization
  • Tasks with independent subtasks that can run in parallel
  • Tasks where quality improves when one agent checks another's work
  • Long-running workflows where a single agent context window would be exceeded

When NOT to use Multi-Agent

  • Simple tasks — the coordination overhead costs more than it saves
  • Tasks where subtasks are tightly coupled and must be done sequentially
  • Early-stage projects where simpler architecture is easier to debug

Multi-Agent implementation

import Anthropic from \"@anthropic-ai/sdk\";

const client = new Anthropic();

interface AgentResult {
  agentName: string;
  output: string;
}

async function runAgent(
  name: string,
  systemPrompt: string,
  task: string
): Promise<AgentResult> {
  const response = await client.messages.create({
    model: \"claude-sonnet-4-20250514\",
    max_tokens: 4096,
    system: systemPrompt,
    messages: [{ role: \"user\", content: task }],
  });

  const output =
    response.content[0].type === \"text\" ? response.content[0].text : \"\";
  return { agentName: name, output };
}

async function multiAgentPipeline(task: string): Promise<string> {
  // Step 1: Orchestrator breaks down the task
  const orchestratorResponse = await client.messages.create({
    model: \"claude-sonnet-4-20250514\",
    max_tokens: 1024,
    system: `You are an orchestrator. Break the given task into subtasks for specialist agents.
Respond with JSON only.
Format: { \"subtasks\": [{ \"agent\": \"agent_name\", \"task\": \"specific task description\" }] }
Available agents: researcher, writer, reviewer`,
    messages: [{ role: \"user\", content: task }],
  });

  const orchText =
    orchestratorResponse.content[0].type === \"text\"
      ? orchestratorResponse.content[0].text
      : \"\";
  const clean = orchText.replace(/```json|```/g, \"\").trim();
  const { subtasks } = JSON.parse(clean) as {
    subtasks: { agent: string; task: string }[];
  };

  // Step 2: Run specialist agents in parallel
  const agentPrompts: Record<string, string> = {
    researcher:
      \"You are a research specialist. Find and summarize relevant facts, data, and context.\",
    writer:
      \"You are a technical writer. Write clear, accurate, well-structured content.\",
    reviewer:
      \"You are a quality reviewer. Check for accuracy, clarity, completeness, and suggest improvements.\",
  };

  const agentPromises = subtasks.map((subtask) =>
    runAgent(
      subtask.agent,
      agentPrompts[subtask.agent] ?? \"You are a helpful assistant.\",
      subtask.task
    )
  );

  const results = await Promise.all(agentPromises);

  // Step 3: Aggregate results
  const aggregatorInput = results
    .map((r) => `## ${r.agentName}\
${r.output}`)
    .join(\"\
\
\");

  const finalResponse = await client.messages.create({
    model: \"claude-sonnet-4-20250514\",
    max_tokens: 4096,
    system:
      \"You are an aggregator. Combine the outputs from multiple specialist agents into a single, coherent final result. Resolve any contradictions. Preserve the best parts of each agent's output.\",
    messages: [
      {
        role: \"user\",
        content: `Original task: ${task}\
\
Agent outputs:\
${aggregatorInput}\
\
Produce the final combined output.`,
      },
    ],
  });

  return finalResponse.content[0].type === \"text\"
    ? finalResponse.content[0].text
    : \"\";
}

// Usage
const result = await multiAgentPipeline(
  \"Write a technical summary of the three most important AI model releases in March 2026\"
);
console.log(result);

Comparison: Which Pattern for Which Task

Task typeBest patternWhy
Web scraping + summarizationReActNext step depends on what you find
Codebase refactorPlan-and-ExecuteBenefits from upfront plan, many files
Research reportMulti-AgentResearch + writing + review in parallel
Bug fixReActExploratory, short, iterative
Database migrationPlan-and-ExecuteSequential, high stakes, plan review needed
Content pipelineMulti-AgentMultiple independent content pieces
API integrationReActTrial and error until it works
Test generationPlan-and-ExecuteEnumerate files, generate tests per file

Combining Patterns

Production systems rarely use one pattern exclusively. A common hybrid:

Multi-Agent orchestrator (outer layer)
├── Research Agent using ReAct (inner loop)
├── Coding Agent using Plan-and-Execute (inner loop)
└── Review Agent using ReAct (inner loop)

The orchestrator assigns work at a high level. Each specialist uses whichever pattern fits its task type. This gives you the specialization benefits of Multi-Agent with the flexibility of ReAct and the structure of Plan-and-Execute where needed.


Framework Support in 2026

FrameworkReActPlan-and-ExecuteMulti-Agent
LangGraph✅ Native✅ Native✅ Native
AutoGen✅ Native
CrewAI✅ Native
Anthropic SDK✅ Manual✅ Manual✅ Manual
OpenAI Agents SDK✅ Native✅ Native

The code examples above use the Anthropic SDK directly with no framework dependency — useful for understanding the patterns before adding framework abstractions.


FAQ

Which pattern is most reliable in production?

Plan-and-Execute tends to be the most reliable for well-defined tasks because the plan step catches ambiguities before execution starts. ReAct is most flexible. Multi-Agent is most powerful but has the most failure points.

How do I handle agent loops that run forever?

Always set a maxIterations limit on ReAct loops. For Plan-and-Execute, set a maximum number of replan cycles. For Multi-Agent, set timeouts on each agent call.

Should I use LangGraph or build my own?

Build your own first for simple tasks — the patterns above are not complex to implement and understanding them directly makes debugging much easier. Reach for LangGraph when you need persistent state, complex branching, or human-in-the-loop checkpoints.

How do I evaluate agent quality?

Define success criteria before building. For coding agents: does the code run and pass tests? For research agents: does the output contain all required information? Automated evals that score agent output against a rubric are more reliable than manual review at scale.


Sources


Next read: Best AI Coding Assistants in 2026: Cursor vs Windsurf vs GitHub Copilot vs Zed