Multi-Agent Orchestration

Lesson 5 · Strands Agents (TypeScript) · ~15 minutes

You've built single agents with tools, state, and hooks. Now the question becomes: how do you compose multiple agents into a system? Strands provides three orchestration patterns — each with different tradeoffs between determinism and flexibility. This is where your interest in controlled, repeatable pipelines meets the need for LLM-driven branching.

Three Patterns, Three Tradeoffs

Every multi-agent system answers one question differently: who decides what runs next?

Graph

Developer defines nodes + edges. The LLM decides which edge to follow at each node. Cycles allowed. Good for conditional branching where you control the topology but not the path.

Swarm

Pool of agents with handoff tools. Agents autonomously decide which peer to delegate to. Emergent path. Good for exploration and creative problem-solving.

Workflow

DAG of tasks with fixed execution order. Independent tasks run in parallel. Fully deterministic — same input always produces the same execution path. Good for repeatable processes.

The core insight: these patterns sit on a spectrum from most deterministic (Workflow) to most autonomous (Swarm), with Graph in the middle giving you structural control with runtime flexibility.

Deterministic ◄──────────────────────────────────────────► Autonomous Workflow Graph Swarm (fixed DAG) (edges defined, (agents decide LLM picks path) handoffs)

Graph: Controlled Branching

A Graph is a set of agent nodes connected by directed edges. You define the topology — which agents can talk to each other. The LLM at each node decides whether to follow an edge or stop. Cycles are allowed, so a reviewer can send work back to a writer.

Basic structure

import { Agent } from '@strands-agents/sdk'
import { Graph } from '@strands-agents/sdk/multi-agent'

// Each node is a standalone agent
const researcher = new Agent({
  name: 'researcher',
  systemPrompt: 'Research the given topic. Provide facts and sources.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const writer = new Agent({
  name: 'writer',
  systemPrompt: 'Write a clear, concise article from the research provided.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

// Define topology: researcher feeds into writer
const graph = new Graph({
  nodes: [researcher, writer],
  edges: [['researcher', 'writer']],
})

const result = await graph.invoke('Research and write about WebAssembly')

The graph engine passes the output of each node as input context to the next. The LLM at the researcher node produces research, which becomes part of the writer node's conversation.

Adding cycles: reviewer loop

Graphs support cycles — a reviewer can reject and send work back to the writer:

const reviewer = new Agent({
  name: 'reviewer',
  systemPrompt: `Review the article for accuracy and clarity.
If it meets standards, approve it.
If not, send it back to the writer with specific feedback.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const graph = new Graph({
  nodes: [researcher, writer, reviewer],
  edges: [
    ['researcher', 'writer'],
    ['writer', 'reviewer'],
    ['reviewer', 'writer'],  // Cycle: reviewer can send back to writer
  ],
  maxCycles: 3,  // Prevent infinite loops
})
Researcher Writer Reviewer Writer
Key Insight

Always set maxCycles on graphs with cycles. Without it, a pedantic reviewer and a stubborn writer can loop indefinitely, burning tokens. Start with maxCycles: 3 and increase only if your use case requires it.

Swarm: Emergent Orchestration

A Swarm gives each agent a handoff tool — the ability to transfer control to another agent in the pool. No edges defined by you. The agents decide among themselves who should handle what.

import { Swarm } from '@strands-agents/sdk/multi-agent'

const analyst = new Agent({
  name: 'analyst',
  systemPrompt: `You analyze data and identify patterns.
If you need creative writing, hand off to the copywriter.
If you need technical details, hand off to the engineer.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const copywriter = new Agent({
  name: 'copywriter',
  systemPrompt: `You write compelling copy from analysis.
If you need more data, hand off back to the analyst.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const engineer = new Agent({
  name: 'engineer',
  systemPrompt: `You provide technical implementation details.
If you need analysis of requirements, hand off to the analyst.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const swarm = new Swarm({
  agents: [analyst, copywriter, engineer],
  entryAgent: 'analyst',  // Who receives the initial input
  maxHandoffs: 5,         // Safety limit
})

const result = await swarm.invoke('Create a product brief for our new API')

The swarm automatically injects handoff tools into each agent. When the analyst decides it needs creative writing, it calls the handoff tool targeting the copywriter. The copywriter receives the conversation context and continues.

Swarm tradeoff

Swarms are powerful for exploration but non-deterministic by nature. The same input can produce different handoff sequences on different runs. Use them for creative/exploratory tasks, not for repeatable business processes. Always set maxHandoffs to prevent runaway delegation.

Workflow: Deterministic DAGs

A Workflow defines a fixed execution order. You specify which tasks depend on which — the engine resolves the DAG, runs independent tasks in parallel, and executes dependent tasks in order. Same input, same execution path, every time.

import { Workflow } from '@strands-agents/sdk/multi-agent'

const researchTask = new Agent({
  name: 'research',
  systemPrompt: 'Research the given topic thoroughly.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const outlineTask = new Agent({
  name: 'outline',
  systemPrompt: 'Create a structured outline from the research.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const writeTask = new Agent({
  name: 'write',
  systemPrompt: 'Write the full article following the outline.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const formatTask = new Agent({
  name: 'format',
  systemPrompt: 'Format the article as clean markdown with headers.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const workflow = new Workflow({
  tasks: [
    { agent: researchTask, dependsOn: [] },
    { agent: outlineTask, dependsOn: ['research'] },
    { agent: writeTask, dependsOn: ['outline'] },
    { agent: formatTask, dependsOn: ['write'] },
  ],
})

// Execution is deterministic: research → outline → write → format
const result = await workflow.invoke('Write about edge computing')

Parallel execution

When tasks have no dependency relationship, the workflow engine runs them concurrently:

const workflow = new Workflow({
  tasks: [
    { agent: researchAgent, dependsOn: [] },
    { agent: competitorAgent, dependsOn: [] },   // Runs parallel with research
    { agent: synthesizerAgent, dependsOn: ['research', 'competitor'] },  // Waits for both
  ],
})
research ──────┐ ├──▶ synthesizer competitor ────┘ (parallel) (waits for both)

This is the pattern for consulting deliverables: research and competitive analysis happen in parallel, synthesis waits for both to complete. The execution graph is fixed — you get repeatability and parallelism without sacrificing control.

Shared State Across Patterns

All three patterns share a common state mechanism. When you invoke a multi-agent system, an invocationState object flows through every node. This is how agents pass structured data — not just conversation text — to each other.

import { Graph, InvocationState } from '@strands-agents/sdk/multi-agent'

// Create shared state accessible to all nodes
const state: InvocationState = {
  results: new Map(),    // Each node's output, keyed by node name
  steps: [],             // Ordered list of which nodes have run
  app: new StateStore(), // Your custom application state
}

const graph = new Graph({
  nodes: [researcher, writer, reviewer],
  edges: [
    ['researcher', 'writer'],
    ['writer', 'reviewer'],
  ],
  invocationState: state,
})

Inside a tool or hook, access the multi-agent state to make decisions:

// In a hook: check what previous nodes produced
const writerOutput = state.results.get('writer')
const researchLength = state.results.get('researcher')?.length

The MultiAgentState structure provides:

FieldTypePurpose
resultsMap<string, string>Output of each completed node, keyed by name
nodesstring[]List of all node names in the system
stepsstring[]Ordered execution trace — which nodes ran and in what order
appStateStoreYour custom application state (counters, flags, accumulated data)

Multi-Agent Hooks

You already know agent-level hooks from Lesson 3. Multi-agent systems add orchestration-level hooks that fire at node boundaries — letting you observe, modify, or cancel node execution.

BeforeNodeCallEvent

Fires before each node agent is invoked. You can inspect the input, modify it, or cancel the node entirely:

import { Graph, BeforeNodeCallEvent } from '@strands-agents/sdk/multi-agent'

const graph = new Graph({
  nodes: [researcher, writer, reviewer],
  edges: [
    ['researcher', 'writer'],
    ['writer', 'reviewer'],
    ['reviewer', 'writer'],
  ],
  hooks: {
    onBeforeNodeCall: (event: BeforeNodeCallEvent) => {
      console.log(`About to call node: ${event.nodeName}`)
      console.log(`Input: ${event.input.slice(0, 100)}...`)

      // Conditionally skip a node
      if (event.nodeName === 'reviewer' && someCondition) {
        event.cancel()  // Skip this node, proceed to next edge or end
      }
    },
  },
})

AfterNodeCallEvent

Fires after a node completes. Inspect outputs, update custom state, log metrics:

hooks: {
  onAfterNodeCall: (event: AfterNodeCallEvent) => {
    console.log(`Node ${event.nodeName} completed`)
    console.log(`Output length: ${event.result.length} chars`)
    console.log(`Stop reason: ${event.stopReason}`)

    // Track execution in custom state
    state.app.set(`${event.nodeName}_completedAt`, Date.now())
  },
}

MultiAgentHandoffEvent (Swarm only)

Fires when an agent in a Swarm hands off to another. Observe the delegation chain:

const swarm = new Swarm({
  agents: [analyst, copywriter, engineer],
  entryAgent: 'analyst',
  hooks: {
    onHandoff: (event: MultiAgentHandoffEvent) => {
      console.log(`${event.fromAgent} → ${event.toAgent}`)
      console.log(`Reason: ${event.reason}`)
    },
  },
})

NodeStreamUpdateEvent

Fires as tokens stream from a node's inner agent. Use this for real-time progress updates in a UI:

hooks: {
  onNodeStreamUpdate: (event: NodeStreamUpdateEvent) => {
    process.stdout.write(event.chunk)  // Stream to terminal
    // Or emit to a WebSocket for live UI updates
  },
}

Practical Example: Content Pipeline with Conditional Review

Let's build the full pattern: a content creation graph where the reviewer is skipped if the writer's output is short (implying a simple, low-risk response that doesn't need review).

import { Agent } from '@strands-agents/sdk'
import {
  Graph,
  BeforeNodeCallEvent,
  AfterNodeCallEvent,
  InvocationState,
  StateStore,
} from '@strands-agents/sdk/multi-agent'

// --- Agents ---
const researcher = new Agent({
  name: 'researcher',
  systemPrompt: `Research the given topic. Provide 3-5 key facts with sources.
Be thorough but concise.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const writer = new Agent({
  name: 'writer',
  systemPrompt: `Write a clear article from the research provided.
Target 200-500 words. Use simple language.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

const reviewer = new Agent({
  name: 'reviewer',
  systemPrompt: `Review the article for accuracy and clarity.
Provide a final approved version with any corrections.`,
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
})

// --- Shared state ---
const state: InvocationState = {
  results: new Map(),
  steps: [],
  nodes: ['researcher', 'writer', 'reviewer'],
  app: new StateStore(),
}

// --- Graph with conditional hook ---
const REVIEW_THRESHOLD = 200  // Skip review for short outputs

const contentPipeline = new Graph({
  nodes: [researcher, writer, reviewer],
  edges: [
    ['researcher', 'writer'],
    ['writer', 'reviewer'],
  ],
  invocationState: state,
  hooks: {
    onAfterNodeCall: (event: AfterNodeCallEvent) => {
      // Store output length for the conditional check
      if (event.nodeName === 'writer') {
        state.app.set('writerOutputLength', event.result.length)
      }
    },
    onBeforeNodeCall: (event: BeforeNodeCallEvent) => {
      // Skip reviewer if writer output is short (low complexity)
      if (event.nodeName === 'reviewer') {
        const writerLength = state.app.get('writerOutputLength') ?? 0
        if (writerLength < REVIEW_THRESHOLD) {
          console.log(
            `Skipping reviewer — writer output (${writerLength} chars) ` +
            `below threshold (${REVIEW_THRESHOLD})`
          )
          event.cancel()
        }
      }
    },
  },
})

// --- Run it ---
const result = await contentPipeline.invoke(
  'Explain what a semaphore is in operating systems'
)

console.log('Execution trace:', state.steps)
console.log('Final output:', result.message?.content)

This pattern gives you structural determinism (researcher → writer → reviewer) with runtime flexibility (skip the reviewer when it's not needed). The hook inspects shared state to make the decision — no LLM involved in the skip logic.

Production pattern

This conditional-skip pattern is common in consulting workflows: skip QA for trivial changes, skip legal review for low-risk content, skip manager approval below a dollar threshold. The hook makes the decision based on structured data, not model judgment.

Decision Framework

When designing a multi-agent system, start with the determinism requirement:

NeedPatternWhy
Fixed process, parallel tasks, repeatable Workflow DAG guarantees execution order. Same input → same path. Independent tasks parallelize automatically.
Conditional branching, cycles, review loops Graph You control the topology (what's possible). The LLM controls the path (what actually happens). Cycles enable feedback loops.
Exploratory, creative, unknown path Swarm Agents self-organize. The system discovers the right sequence through handoffs. Best when you can't predict the workflow in advance.

Combining patterns

These patterns compose. A Workflow step can itself be a Graph. A Graph node can be a single agent with complex tools. Use the simplest pattern that satisfies your requirements at each level:

// A workflow where one step is itself a graph
const researchGraph = new Graph({
  nodes: [webSearcher, factChecker],
  edges: [['webSearcher', 'factChecker'], ['factChecker', 'webSearcher']],
  maxCycles: 2,
})

const pipeline = new Workflow({
  tasks: [
    { agent: researchGraph, dependsOn: [] },
    { agent: writerAgent, dependsOn: ['research'] },
    { agent: formatterAgent, dependsOn: ['write'] },
  ],
})
Design heuristic

Start with Workflow. Only reach for Graph when you need cycles or conditional edges. Only reach for Swarm when you genuinely don't know the path upfront. Each step up the autonomy spectrum is harder to debug and predict.

Key Takeaways

You need to build a document processing pipeline: extract text, classify it, then route to either legal review or standard formatting based on the classification. Which pattern fits best?
Correct. The conditional routing (legal vs. standard) based on content makes this a Graph problem. You define the topology (classifier → legal, classifier → formatter) and the LLM at the classifier node decides which edge to follow based on document content.
The key signal is "route based on classification" — that's conditional branching. A Workflow can't branch. A Swarm is overkill (you know the possible paths). A Graph lets you define both paths as edges and let the classifier node's LLM decide which to follow.
In the content pipeline example, why does the conditional review-skip use a hook (BeforeNodeCallEvent) instead of putting the skip logic in the reviewer's system prompt?
Correct. The hook executes deterministic code — a simple length check. No model invocation means zero token cost, zero latency, and zero chance of the model deciding to review anyway. For structural control decisions (skip/proceed), always prefer hooks over prompts.
The key advantage is determinism. A prompt instruction ("skip if short") still invokes the model (tokens, latency) and the model might ignore it. A hook is plain TypeScript — if the condition is true, the node is cancelled. No ambiguity, no cost.
What happens when a Swarm agent calls the handoff tool but maxHandoffs has been reached?
Correct. Consistent with Strands' error-recovery philosophy (from Lesson 1): the handoff failure is returned as a tool result. The current agent sees "handoff denied" and must wrap up on its own. The loop doesn't crash — it degrades gracefully.
Remember Strands' error handling pattern from Lesson 1: tool failures become error results, not exceptions. The same applies to handoff tools — when the limit is hit, the current agent gets an error result and must finish the task itself.
📖 Primary Source

Multi-Agent Patterns — Strands Agents Docs — Official documentation on Graph, Swarm, and Workflow patterns with additional examples and API reference. ~12 min read.

💬 Questions? Ask me about choosing between patterns, composing multi-agent systems, debugging handoff chains, or how these map to real consulting delivery workflows.
← Back Next →