> ## Documentation Index
> Fetch the complete documentation index at: https://docs.symbolica.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Building Multi-Agent Systems

> Orchestrate multiple agents to solve complex problems

## Overview

Multi-agent systems involve multiple agents working together to solve complex problems. While other frameworks require you to find contrived ways to have agents spawn agents, **the Agentica SDK makes this trivial**: pass an agentic function or an agent to another agentic function or agent.

That's it. No special orchestration layers, no graphs, no composition primitives or DSL, no message-passing protocols, no global state management. It's just code.

### When to Use Multi-Agent Systems

Multi-agent systems shine when:

* **Tasks can be parallelized**: Multiple independent sub-tasks may be worked on simultaneously
* **Specialization helps**: Different agents with different capabilities or contexts tackle different aspects
* **Complex workflows**: The problem naturally breaks down into distinct phases or responsibilities
* **Iterative refinement**: One agent's output becomes another agent's input

### The Core Concept

Because the Agentica SDK allows you to pass **any** runtime value into scope -- functions, objects, classes, SDKs -- this naturally includes:

* The `spawn()` function itself
* Existing agents
* Agentic functions
* Custom orchestration logic

When you pass these to an agent or agentic function, they can use them just like anything else in [scope](/concepts/scope). No schemas, no special handling. It's just code.

## Pattern: Basic Composition

Let's start with the simplest possible multi-agent pattern: calling one agentic function from another.

<CodeGroup>
  ```python Python theme={null}
  from agentica import agentic
  from dataclasses import dataclass

  @dataclass
  class CodeAnalysis:
      bugs: list[str]
      style_issues: list[str]
      performance_issues: list[str]

  # Two separate agentic functions, each specialized
  @agentic()
  async def analyze_code(code: str) -> CodeAnalysis:
      """Analyze code for bugs, style issues, and performance problems."""
      ...

  @agentic()
  async def fix_code(code: str, analysis: CodeAnalysis) -> str:
      """Fix the code based on the analysis."""
      ...

  # Compose them together
  analysis = await analyze_code(code)
  fixed = await fix_code(code, analysis)
  ```

  ```typescript TypeScript theme={null}
  import { agentic } from '@symbolica/agentica';

  interface CodeAnalysis {
    bugs: string[];
    styleIssues: string[];
    performanceIssues: string[];
  }

  // Two separate agentic functions, each specialized
  async function analyzeCode(code: string): Promise<CodeAnalysis> {
    return await agentic('Analyze code for bugs and performance issues', { code });
  }

  async function fixCode(code: string, analysis: CodeAnalysis): Promise<string> {
    return await agentic('Fix the code based on the analysis', { code, analysis });
  }

  // Compose them together
  const analysis = await analyzeCode(code);
  const fixed = await fixCode(code, analysis);
  ```
</CodeGroup>

This is a multi-agent system: two separate agent invocations, each specialized for its task, composed together. However the "workflow" here is very boring -- it's entirely serial, with no conditionality. Let's take a look at what introducing control flow is like.

## Pattern: External Control Flow

In this pattern, **you orchestrate** the agents from your code. You decide when to call agents, what tasks to give them, and how to combine their results. You write the control flow: loops, conditionals, error handling -- all the logic that coordinates the agents.

This is useful when:

* The workflow involves iteration, conditionals, or complex branching
* You want explicit control over execution order and conditions
* You need to inject custom logic between agent calls
* You're building a specific, repeatable process with clear control flow

### Example: Parallelization, Iteration, and Conditional Logic

<CodeGroup>
  ```python Python theme={null}
  from agentica import agentic

  ... # other logic and classes

  @agentic()
  async def evaluate_candidate(cand: Candidate) -> EvaluationResult:
      """Evaluate if candidate meets our technical and cultural requirements."""
      ...

  @agentic()
  async def create_interview_plan(cand: Candidate, eval: EvaluationResult) -> InterviewPlan:
      """Create a tailored interview plan focusing on the candidate's strengths and gaps."""
      ...

  @agentic()
  async def create_rejection_letter(cand: Candidate, eval: EvaluationResult) -> RejectionLetter:
      """Generate a polite rejection letter with constructive feedback."""
      ...

  async def pipeline(candidates: list[Candidate]):
      # Evaluate all candidates in parallel
      evaluations = await asyncio.gather(*[evaluate_candidate(c) for c in candidates])

      to_be_interviewed: dict[Candidate, InterviewPlan] = dict()
      to_be_rejected: dict[Candidate, RejectionLetter] = dict()

      # Branch based on results
      async def pick_candidate(candidate: Candidate, evaluation: EvaluationResult):
          if evaluation.is_qualified:
              to_be_interviewed[candidate] = await create_interview_plan(candidate, evaluation)
          else:
              to_be_rejected[candidate] = await create_rejection_letter(candidate, evaluation)

      async with asyncio.TaskGroup() as tg:
          for candidate, evaluation in zip(candidates, evaluations):
              tg.create_task(pick_candidate(candidate, evaluation))

      # Do something with the data
      ...
  ```

  ```typescript TypeScript theme={null}
  import { agentic } from '@symbolica/agentica';

  ... // other logic and classes

  async function evaluateCandidate(cand: Candidate): Promise<EvaluationResult> {
    return agentic('Evaluate if candidate meets our technical and cultural requirements', { cand });
  }

  async function createInterviewPlan(cand: Candidate, eval: EvaluationResult): Promise<InterviewPlan> {
    return agentic("Create a tailored interview plan focusing on the candidate's strengths and gaps", { cand, eval });
  }

  async function createRejectionLetter(cand: Candidate, eval: EvaluationResult): Promise<RejectionLetter> {
    return agentic('Generate a polite rejection letter with constructive feedback', { cand, eval });
  }

  async function pipeline(candidates: Candidate[]) {
    // Evaluate all candidates in parallel
    const evaluations = await Promise.all(candidates.map(c => evaluateCandidate(c)));

    const toBeInterviewed: Map<Candidate, InterviewPlan> = new Map();
    const toBeRejected: Map<Candidate, RejectionLetter> = new Map();

    // Process qualified and unqualified candidates concurrently
    await Promise.all(
      candidates.map(async (candidate, i) => {
        const evaluation = evaluations[i];

        if (evaluation.isQualified) {
          const plan = await createInterviewPlan(candidate, evaluation);
          toBeInterviewed.set(candidate, plan);
        } else {
          const letter = await createRejectionLetter(candidate, evaluation);
          toBeRejected.set(candidate, letter);
        }
      })
    );

    // Do something with the data
    ...
  }
  ```
</CodeGroup>

<Note>
  Notice how **you** write the parallelization, the conditional branching, the iteration logic. Agents are just functions you call -- the control flow is yours. This is just regular programming with agents as building blocks.
</Note>

## Pattern: Coordinator + Workers

Now here's where the Agentica SDK's design truly shines. Instead of **you** controlling the flow, **let an agent control it**. A coordinator agent manages multiple worker agents, each handling independent sub-tasks.

This is useful when:

* The number of sub-tasks isn't known in advance
* The workflow needs to adapt based on intermediate results
* You want agents to decide how to parallelize work
* Sub-tasks benefit from specialization
* The problem is too complex for fixed orchestration logic

### Most Constrained: Passing Specific Agent Objects

Let's start with the most constrained approach: pass pre-configured agent objects with specific capabilities.

<CodeGroup>
  ```python Python theme={null}
  from agentica import spawn

  # Create specialized agents with permanent tool access
  data_analyst = await spawn(
      premise="You are a data analyst",
      scope={"query_database": query_database}    # ← Functions at spawn time
  )
  market_analyst = await spawn(
      premise="You are a market analyst",
      scope={"get_market_data": get_market_data}  # ← Functions at spawn time
  )

  coordinator = await spawn(premise="You are a research coordinator")

  report = await coordinator.call(
      ComprehensiveReport,             # ← The rigid class defining the report type
      f"Research {topic} using the data analyst and market analyst",
      data_analyst=data_analyst,       # ← Pass agent objects
      market_analyst=market_analyst    # ← Just like anything else
  )
  ```

  ```typescript TypeScript theme={null}
  import { spawn } from '@symbolica/agentica';

  // Create specialized agents with permanent tool access
  await using dataAnalyst = await spawn(
    { premise: "You are a data analyst" },
    { queryDatabase }    // ← Functions at spawn time
  );
  await using marketAnalyst = await spawn(
    { premise: "You are a market analyst" },
    { getMarketData }    // ← Functions at spawn time
  );

  await using coordinator = await spawn({ premise: "You are a research coordinator" });

  const report = await coordinator.call<ComprehensiveReport>(  // ← The rigid type defining the report structure
    `Research ${topic} using the data analyst and market analyst`,
    {
      dataAnalyst,    // ← Pass agent objects
      marketAnalyst   // ← Just like anything else
    }
  );
  ```
</CodeGroup>

The coordinator can now invoke the specialist agents, pass data between them, and decide when to use which one -- all dynamically based on the task.

### Least Constrained: Passing `spawn` Itself

For maximum flexibility, pass `spawn` itself so the coordinator can create any agents it needs:

<CodeGroup>
  ```python Python theme={null}
  from agentica import spawn

  ... # other logic and classes

  async def web_search(query: str) -> list[SearchResult]: ...

  # Create a coordinator with the ability to spawn
  coordinator = await spawn(premise="You are a research coordinator who can spawn sub-agents")

  # Give it spawn and tools - it decides how many sub-agents it needs
  report = await coordinator.call(
      ResearchReport,       # ← The rigid return type defining the report
      f"Research '{topic}' thoroughly using sub-agents for different aspects",
      spawn=spawn,          # ← The interesting part happens here
      web_search=web_search # ← A function that may be passed to sub-agents
  )
  ```

  ```typescript TypeScript theme={null}
  import { spawn } from '@symbolica/agentica';

  ... // other logic and classes

  async function webSearch(query: string): Promise<SearchResult[]> { ... }

  await using coordinator = await spawn({ premise: 'You are a research coordinator who can spawn sub-agents'});

  // Give it spawn and tools - it decides how many sub-agents it needs
  const report = await coordinator.call<ResearchReport>(  // ← The rigid return type defining the report
    `Research '${topic}' thoroughly using sub-agents for different aspects`,
    {
      spawn,    // ← The interesting part happens here
      webSearch // ← A function that may be passed to sub-agents
    }
  );
  ```
</CodeGroup>

What just happened? The coordinator agent can now:

* Decide how many sub-agents it needs
* Spawn them dynamically using `spawn()`
* Assign them specific tasks
* Wait for their results
* Synthesize everything into a final report

**You didn't write any of that orchestration logic.** The agent figured it out.

<Tip>
  For a richer case of this pattern, see the [Deep Research example](/guides/examples#DeepResearch).
</Tip>

## Best Practices

### 1. Choose the Right Pattern

* **Basic composition**: For simple sequential agentic operations
* **External control flow**: For predictable processes with iteration and conditionals
* **Coordinator + workers**: For dynamic, adaptive workflows where agents orchestrate other agents

### 2. Be Clear About Responsibilities

Give each agent a focused premise that defines its role:

<CodeGroup>
  ```python Python theme={null}
  researcher = await spawn(
      premise="You are a researcher who finds and verifies facts. Do not write content."
  )

  writer = await spawn(
      premise="You are a writer who creates engaging content from facts. Do not do research."
  )
  ```

  ```typescript TypeScript theme={null}
  await using researcher = await spawn({
    premise: "You are a researcher who finds and verifies facts. Do not write content."
  });

  await using writer = await spawn({
    premise: "You are a writer who creates engaging content from facts. Do not do research."
  });
  ```
</CodeGroup>

### 3. Use Appropriate Scope

Only pass what each agent needs:

<CodeGroup>
  ```python Python theme={null}
  # Good: Focused scope
  researcher = await spawn()
  result = await researcher.call(ResearchReport, "Research X", web_search=web_search)

  # Avoid: Too much scope
  result = await researcher.call(
      ResearchReport,
      "Research X",
      web_search=web_search,
      database=db,
      api_client=client  # Researcher doesn't need these
  )
  ```

  ```typescript TypeScript theme={null}
  // Good: Focused scope
  await using researcher = await spawn();
  const result = await researcher.call<ResearchReport>("Research X", { webSearch });

  // Avoid: Too much scope
  const result = await researcher.call<ResearchReport>(
    "Research X",
    {
      webSearch,
      database: db,
      apiClient: client  // Researcher doesn't need these
    }
  );
  ```
</CodeGroup>

## Why This Works in the Agentica SDK

In traditional agent frameworks, multi-agent systems require:

* Message-passing protocols
* State management systems
* Complex orchestration layers
* Schema definitions for inter-agent communication

With the Agentica SDK, **none of that is needed** because:

1. **Everything is just scope**: Agents, functions, objects -- they're all just values you can pass around
2. **Warp handles communication**: The framework automatically manages calls between your runtime and the sandbox
3. **Proxies enable references**: Agents can pass complex objects without serialization
4. **REPL enables agency**: Agents can write actual code to orchestrate other agents, not just call predefined tools

The result: multi-agent systems that feel like regular programming, not framework wrangling.

## Next Steps

<CardGroup cols={2}>
  <Card title="Error Handling" icon="shield" href="/guides/agent-errors">
    Handle errors in multi-agent systems
  </Card>

  <Card title="Best Practices" icon="star" href="/guides/best-practices">
    Production-ready patterns
  </Card>

  <Card title="Human-in-the-Loop" icon="hand" href="/guides/human-in-the-loop">
    Add human oversight to agents
  </Card>

  <Card title="Examples" icon="code" href="/guides/examples#DeepResearch">
    See the Deep Research multi-agent example
  </Card>
</CardGroup>
