Multi-Agent Patterns in Python with 10xGraph

In shortBuild supervisor, swarm, manual handoff and plan-act-reflect multi-agent systems in 10xGraph where each agent runs on a different company's LLM.

  • 12 min read
  • 11 sections
  • Updated
  • v1.0.0rc4
  • Markdown

This example shows four multi-agent shapes: a supervisor team, a swarm, a manual handoff graph and a plan, act and reflect loop. In every file the agents run on models from different companies (Anthropic, OpenAI, Google and DeepSeek), because each agent is its own Agent with its own model string.

What you will build

Four multi-agent systems, each with a different shape of control flow. A supervisor routes work to specialists. A swarm lets peers hand work to each other. A manual graph shows how to wire handoffs yourself. A plan, act and reflect loop separates planning from review.

In every file the agents run on models from different companies. That is possible because each agent is its own Agent with its own model string, and 10xGraph converts messages for each provider.

Which pattern when

Pattern File Use it when Control flow
Supervisor team supervisor.py One coordinator should decide who works next and when to stop Supervisor, worker, supervisor, … until FINISH
Swarm swarm.py Specialists know best who should take over, and you want no central router Agent hands off directly to a peer
Manual handoff handoff_manual.py You need custom edges, tool nodes or state beyond what SwarmAgent wires create_handoff_tool on your own StateGraph
Plan, act, reflect plan_act_reflect.py A task needs tool use followed by a critique pass and possible retry PLAN, ACT, REFLECT, repeat until [DONE]

Which agent uses which model

File Agent Company Model
supervisor.py Supervisor Anthropic claude-sonnet-5-5
RESEARCHER OpenAI gpt-5-mini
ANALYST Google gemini-2.5-flash
WRITER DeepSeek deepseek-chat
swarm.py TRIAGE OpenAI gpt-5-mini
RESEARCHER Anthropic claude-sonnet-5-5
WRITER Google gemini-2.5-flash
handoff_manual.py coordinator Google gemini-2.5-flash
researcher DeepSeek deepseek-chat
writer Anthropic claude-sonnet-5-5
plan_act_reflect.py PLAN Anthropic claude-sonnet-5-5
ACT none (runs tools) not applicable
REFLECT Google gemini-2.5-flash

DeepSeek is reached through its OpenAI-compatible endpoint. 10xGraph treats an unknown model name as an OpenAI-style model, so base_url and api_key on the Agent are all it needs.

Key concepts

  • SupervisorTeamAgent(supervisor_model=..., workers=\{...\}): builds the supervisor from a model string and accepts pre-built worker agents.
  • WorkerConfig(agent=..., description=...): a worker and the description the supervisor reads when choosing.
  • SwarmAgent(members=\{...\}, entry=...): wires peer handoffs and injects a transfer_to_<member> tool into each member.
  • SwarmMemberConfig(agent=..., can_handoff_to=[...], description=...): one member and its allowed targets.
  • create_handoff_tool(name, description): a tool that moves control to the node name without running.
  • PlanActReflectAgent(model, plan_model=..., reflect_model=..., tools=[...], max_iterations=...): planner, tool step and reviewer loop.
  • Agent(model=..., base_url=..., api_key=...): per-agent provider settings.

Run it

Terminal
git clone https://github.com/10xGraph/10xGraph.git
cd 10xGraph/agentflow
Terminal
pip install "10xgraph[openai,anthropic,google-genai]" python-dotenv
export OPENAI_API_KEY="..."
export ANTHROPIC_API_KEY="..."
export GEMINI_API_KEY="..."
export DEEPSEEK_API_KEY="..."

The openai extra also covers DeepSeek. Each script needs only the keys for the models it uses, as listed in its docstring.

Terminal
python examples/multi_agent/supervisor.py
python examples/multi_agent/swarm.py
python examples/multi_agent/handoff_manual.py
python examples/multi_agent/plan_act_reflect.py

Each script prints every assistant reply with the model that produced it.

How the code works

supervisor.py

Workers are normal agents. Only the supervisor is built from a string.

Python
researcher = Agent(
    model="gpt-5-mini",  # OpenAI
    system_prompt=[
        {"role": "system", "content": "You research facts. Use web_search, then report findings."}
    ],
    tool_node=ToolNode([web_search]),
)

The researcher runs on OpenAI and the analyst on Google. Each has its own tool node and system prompt.

Python
writer = Agent(
    model="deepseek-chat",  # DeepSeek, through its OpenAI-compatible endpoint
    base_url="https://api.deepseek.com",
    api_key=os.environ["DEEPSEEK_API_KEY"],
    system_prompt=[
        {"role": "system", "content": "You write a concise summary from the work done so far."}
    ],
)

The writer uses DeepSeek through its OpenAI-compatible endpoint, so base_url and api_key are set on this agent only.

Python
team = SupervisorTeamAgent(
    supervisor_model="claude-sonnet-5-5",  # Anthropic
    workers={
        "RESEARCHER": WorkerConfig(agent=researcher, description="Finds facts with web search."),
        "ANALYST": WorkerConfig(agent=analyst, description="Does calculations and comparisons."),
        "WRITER": WorkerConfig(agent=writer, description="Writes the final answer for the user."),
    },
    max_rounds=6,  # Upper bound on supervisor -> worker hops per turn.
)

The supervisor runs on Anthropic. It replies with one worker name or FINISH, and its choice is not added to the conversation. The description of each worker is what it reads when choosing. A worker that has tools gets its own tool loop. max_rounds caps the hops per turn.

Python
    for msg in result["messages"]:
        if msg.role == "assistant" and msg.text():
            model = (msg.metadata or {}).get("model") or "unknown model"
            print(f"[{model}] {msg.text()[:300]}\n")

Each reply is printed with the model that produced it, read from msg.metadata["model"]. This is how you can see the mix of providers in one run.

swarm.py

Members are pre-built agents too: triage on OpenAI, researcher on Anthropic, writer on Google.

Python
swarm = SwarmAgent(
    members={
        "TRIAGE": SwarmMemberConfig(
            agent=triage,
            can_handoff_to=["RESEARCHER", "WRITER"],
            description="Routes requests to the right specialist.",
        ),
        "RESEARCHER": SwarmMemberConfig(
            agent=researcher,
            can_handoff_to=["WRITER"],
            description="Researches a topic and gathers facts.",
        ),
        # No can_handoff_to: the writer ends the run when it replies without a handoff.
        "WRITER": SwarmMemberConfig(agent=writer, description="Writes and saves articles."),
    },
    entry="TRIAGE",
)

can_handoff_to limits who each member can pass work to, and None (the default) means everyone else. Do not add handoff tools yourself, because SwarmAgent injects a transfer_to_<member> tool into every member. A member that replies without a handoff or tool call ends the run, so the writer finishes the conversation.

handoff_manual.py

Use this when SwarmAgent’s wiring is not what you need. The target name in create_handoff_tool must equal a node name in the graph and is used as written, so the nodes here are lowercase on purpose.

Python
def route_to(tool_node: str):
    """Go to the agent's tool node when the last reply has tool calls, else finish."""

    def route(state: AgentState) -> str:
        last = state.context[-1] if state.context else None
        return tool_node if last and last.role == "assistant" and last.tools_calls else END

This router sends control to the agent’s tool node when the last reply has tool calls, and ends otherwise.

Python
    for name, (agent, tools) in agents.items():
        graph.add_node(name, agent)
        graph.add_node(f"{name}_tools", ToolNode(tools))
        graph.add_conditional_edges(
            name, route_to(f"{name}_tools"), {f"{name}_tools": f"{name}_tools", END: END}
        )
        # After a regular tool, return to the same agent. A transfer tool skips this edge
        # because its Command already names the next node.
        graph.add_edge(f"{name}_tools", name)

Each agent has a ToolNode holding its own tools and transfer tools. The tool node sees a transfer_to_<name> call and returns a jump to that node, so the transfer tool itself never runs. The \{name\}_tools -> \{name\} edge returns control to the same agent after an ordinary tool. A transfer tool skips that edge because its Command already names the next node.

Python
    graph.set_entry_point("coordinator")
    return graph

plan_act_reflect.py

Python
agent = PlanActReflectAgent(
    model="claude-sonnet-5-5",  # Fallback for any phase without its own model.
    plan_model="claude-sonnet-5-5",  # Anthropic plans and chooses tools.
    reflect_model="gemini-2.5-flash",  # Google reviews the result.
    tools=[search_flights, search_hotels],
    max_iterations=3,
)

model is the fallback for a phase with no override. PLAN is an LLM that decides which tools to call, ACT is the ToolNode that executes them with no model call, and REFLECT is an LLM that reviews the work. REFLECT ends the loop by including [DONE], or sends it back to PLAN until max_iterations. Here Anthropic plans and Google reflects.

Extra keyword arguments such as base_url apply to both model phases, so pick providers that can share them. Use supervisor.py when each agent needs its own endpoint.

Full code

supervisor.py

Python
"""Supervisor team: one model routes work to specialist workers, each from a different company.

SupervisorTeamAgent builds the supervisor itself from `supervisor_model` (Anthropic here).
Each worker is an ordinary Agent you construct, so it carries its own model, tools and
endpoint: OpenAI, Gemini and DeepSeek below. The supervisor answers with a worker name or
FINISH, and the graph follows.

Run: python examples/multi_agent/supervisor.py
     (needs ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY)
"""

import asyncio
import os

from dotenv import load_dotenv

from tenxgraph.core.graph import Agent, ToolNode
from tenxgraph.core.state import Message
from tenxgraph.prebuilt.agent import SupervisorTeamAgent, WorkerConfig


load_dotenv()


def web_search(query: str) -> str:
    """Search the web and return a short list of findings."""
    return (
        f"Findings for '{query}': global EV sales grew about 25% year over year; "
        "China accounts for roughly 60% of new registrations; battery pack prices fell 14%."
    )


def calculate_growth(start: float, end: float) -> str:
    """Return the percentage change from start to end."""
    return f"{(end - start) / start * 100:.1f}% change"


researcher = Agent(
    model="gpt-5-mini",  # OpenAI
    system_prompt=[
        {"role": "system", "content": "You research facts. Use web_search, then report findings."}
    ],
    tool_node=ToolNode([web_search]),
)

analyst = Agent(
    model="gemini-2.5-flash",  # Google
    system_prompt=[
        {"role": "system", "content": "You analyse numbers. Use calculate_growth when useful."}
    ],
    tool_node=ToolNode([calculate_growth]),
)

writer = Agent(
    model="deepseek-chat",  # DeepSeek, through its OpenAI-compatible endpoint
    base_url="https://api.deepseek.com",
    api_key=os.environ["DEEPSEEK_API_KEY"],
    system_prompt=[
        {"role": "system", "content": "You write a concise summary from the work done so far."}
    ],
)

team = SupervisorTeamAgent(
    supervisor_model="claude-sonnet-5-5",  # Anthropic
    workers={
        "RESEARCHER": WorkerConfig(agent=researcher, description="Finds facts with web search."),
        "ANALYST": WorkerConfig(agent=analyst, description="Does calculations and comparisons."),
        "WRITER": WorkerConfig(agent=writer, description="Writes the final answer for the user."),
    },
    max_rounds=6,  # Upper bound on supervisor -> worker hops per turn.
)


async def main() -> None:
    app = team.compile()
    result = await app.ainvoke(
        {"messages": [Message.text_message("Brief me on the electric vehicle market.")]},
        config={"thread_id": "supervisor-1", "recursion_limit": 40},
    )
    for msg in result["messages"]:
        if msg.role == "assistant" and msg.text():
            model = (msg.metadata or {}).get("model") or "unknown model"
            print(f"[{model}] {msg.text()[:300]}\n")
    await app.aclose()


if __name__ == "__main__":
    asyncio.run(main())

swarm.py

Python
"""Swarm: peer agents hand work to each other, and each peer runs on a different company's model.

SwarmAgent injects a `transfer_to_<member>` tool into every member. When a model calls
one, control moves to that member. No supervisor is involved. Triage runs on OpenAI, the
researcher on Anthropic and the writer on Gemini.

Run: python examples/multi_agent/swarm.py
     (needs OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY)
"""

import asyncio

from dotenv import load_dotenv

from tenxgraph.core.graph import Agent, ToolNode
from tenxgraph.core.state import Message
from tenxgraph.prebuilt.agent import SwarmAgent, SwarmMemberConfig


load_dotenv()


def web_search(query: str) -> str:
    """Search the web and return findings."""
    return f"Findings for '{query}': solid-state batteries promise 2x energy density by 2030."


def save_document(title: str, body: str) -> str:
    """Save a finished document."""
    return f"Saved '{title}' ({len(body)} characters)."


triage = Agent(
    model="gpt-5-mini",  # OpenAI
    system_prompt=[
        {
            "role": "system",
            "content": "Route the request. Research questions go to the researcher; "
            "writing-only requests go to the writer. Do not answer yourself.",
        }
    ],
)

researcher = Agent(
    model="claude-sonnet-5-5",  # Anthropic
    system_prompt=[
        {
            "role": "system",
            "content": "Research with web_search. When you have findings, hand off to the writer.",
        }
    ],
    tool_node=ToolNode([web_search]),
)

writer = Agent(
    model="gemini-2.5-flash",  # Google
    system_prompt=[
        {"role": "system", "content": "Write a short article from the conversation, then save it."}
    ],
    tool_node=ToolNode([save_document]),
)

swarm = SwarmAgent(
    members={
        "TRIAGE": SwarmMemberConfig(
            agent=triage,
            can_handoff_to=["RESEARCHER", "WRITER"],
            description="Routes requests to the right specialist.",
        ),
        "RESEARCHER": SwarmMemberConfig(
            agent=researcher,
            can_handoff_to=["WRITER"],
            description="Researches a topic and gathers facts.",
        ),
        # No can_handoff_to: the writer ends the run when it replies without a handoff.
        "WRITER": SwarmMemberConfig(agent=writer, description="Writes and saves articles."),
    },
    entry="TRIAGE",
)


async def main() -> None:
    app = swarm.compile()
    result = await app.ainvoke(
        {"messages": [Message.text_message("Write a short article on solid-state batteries.")]},
        config={"thread_id": "swarm-1", "recursion_limit": 30},
    )
    for msg in result["messages"]:
        if msg.role == "assistant" and msg.text():
            model = (msg.metadata or {}).get("model") or "unknown model"
            print(f"[{model}] {msg.text()[:300]}\n")
    await app.aclose()


if __name__ == "__main__":
    asyncio.run(main())

handoff_manual.py

Python
"""Manual handoff: wire agent-to-agent transfers on your own StateGraph with create_handoff_tool.

Use this when SwarmAgent's wiring is not what you need. Each agent gets a ToolNode that
holds its regular tools and transfer tools. When the model calls `transfer_to_<node>`,
the tool node returns a Command that jumps to that node without running the tool.
Agents: coordinator on Gemini, researcher on DeepSeek, writer on Anthropic.

Run: python examples/multi_agent/handoff_manual.py
     (needs GEMINI_API_KEY, DEEPSEEK_API_KEY, ANTHROPIC_API_KEY)
"""

import asyncio
import os

from dotenv import load_dotenv

from tenxgraph.core.graph import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState, Message
from tenxgraph.prebuilt.tools import create_handoff_tool
from tenxgraph.utils.constants import END


load_dotenv()


def search_web(query: str) -> str:
    """Search the web for information."""
    return (
        f"Results for '{query}': quantum computers use qubits; error correction is the main hurdle."
    )


def write_document(title: str, content: str) -> str:
    """Save a document."""
    return f"Document '{title}' saved ({len(content)} characters)."


# The target name in create_handoff_tool must equal a node name in the graph. It is used as
# written, so these nodes are lowercase on purpose.
coordinator = Agent(
    model="gemini-2.5-flash",  # Google
    system_prompt=[
        {
            "role": "system",
            "content": "You coordinate. Send research to the researcher and writing to the writer. "
            "When the writer returns, give the user a one-line wrap-up.",
        }
    ],
    tool_node="coordinator_tools",
)
researcher = Agent(
    model="deepseek-chat",  # DeepSeek
    base_url="https://api.deepseek.com",
    api_key=os.environ["DEEPSEEK_API_KEY"],
    system_prompt=[
        {"role": "system", "content": "Research with search_web, then transfer to the writer."}
    ],
    tool_node="researcher_tools",
)
writer = Agent(
    model="claude-sonnet-5-5",  # Anthropic
    system_prompt=[
        {
            "role": "system",
            "content": "Write a short article, save it with write_document, "
            "then transfer back to the coordinator.",
        }
    ],
    tool_node="writer_tools",
)


def route_to(tool_node: str):
    """Go to the agent's tool node when the last reply has tool calls, else finish."""

    def route(state: AgentState) -> str:
        last = state.context[-1] if state.context else None
        return tool_node if last and last.role == "assistant" and last.tools_calls else END

    return route


def build_graph() -> StateGraph:
    graph = StateGraph()
    agents = {
        "coordinator": (
            coordinator,
            [
                create_handoff_tool("researcher", "Hand off a research task."),
                create_handoff_tool("writer", "Hand off a writing task."),
            ],
        ),
        "researcher": (
            researcher,
            [search_web, create_handoff_tool("writer", "Hand off findings to the writer.")],
        ),
        "writer": (
            writer,
            [write_document, create_handoff_tool("coordinator", "Return to the coordinator.")],
        ),
    }
    for name, (agent, tools) in agents.items():
        graph.add_node(name, agent)
        graph.add_node(f"{name}_tools", ToolNode(tools))
        graph.add_conditional_edges(
            name, route_to(f"{name}_tools"), {f"{name}_tools": f"{name}_tools", END: END}
        )
        # After a regular tool, return to the same agent. A transfer tool skips this edge
        # because its Command already names the next node.
        graph.add_edge(f"{name}_tools", name)

    graph.set_entry_point("coordinator")
    return graph


async def main() -> None:
    app = build_graph().compile()
    result = await app.ainvoke(
        {
            "messages": [
                Message.text_message("Research quantum computing and write a short article.")
            ]
        },
        config={"thread_id": "handoff-1", "recursion_limit": 30},
    )
    for msg in result["messages"]:
        if msg.role == "assistant" and msg.text():
            model = (msg.metadata or {}).get("model") or "unknown model"
            print(f"[{model}] {msg.text()[:300]}\n")
    await app.aclose()


if __name__ == "__main__":
    asyncio.run(main())

plan_act_reflect.py

Python
"""Plan, act, reflect: a planner and a reviewer on different companies' models.

PlanActReflectAgent loops PLAN -> ACT -> REFLECT until the reflector says [DONE] or
`max_iterations` is reached. PLAN is an LLM that decides which tools to call. ACT is the
ToolNode that runs them (no model). REFLECT is an LLM that reviews the work. `plan_model`
and `reflect_model` set a model per phase: Anthropic plans and Gemini reflects.

Run: python examples/multi_agent/plan_act_reflect.py   (needs ANTHROPIC_API_KEY, GEMINI_API_KEY)
"""

import asyncio

from dotenv import load_dotenv

from tenxgraph.core.state import Message
from tenxgraph.prebuilt.agent import PlanActReflectAgent


load_dotenv()


def search_flights(origin: str, destination: str) -> str:
    """Search flights between two cities."""
    return f"{origin} -> {destination}: 08:10 at 240 EUR, 13:45 at 190 EUR, 19:30 at 165 EUR."


def search_hotels(city: str, nights: int) -> str:
    """Search hotels in a city."""
    return f"{city}, {nights} nights: Hotel Aurora 110 EUR/night, Casa Verde 85 EUR/night."


agent = PlanActReflectAgent(
    model="claude-sonnet-5-5",  # Fallback for any phase without its own model.
    plan_model="claude-sonnet-5-5",  # Anthropic plans and chooses tools.
    reflect_model="gemini-2.5-flash",  # Google reviews the result.
    tools=[search_flights, search_hotels],
    max_iterations=3,
)


async def main() -> None:
    app = agent.compile()
    result = await app.ainvoke(
        {
            "messages": [
                Message.text_message("Plan a 3-night trip from Berlin to Lisbon under 600 EUR.")
            ]
        },
        config={"thread_id": "par-1", "recursion_limit": 40},
    )
    for msg in result["messages"]:
        if msg.role == "assistant" and msg.text():
            model = (msg.metadata or {}).get("model") or "unknown model"
            print(f"[{model}] {msg.text()[:300]}\n")
    await app.aclose()


if __name__ == "__main__":
    asyncio.run(main())

Source on GitHub: examples/multi_agent

Expected output

Illustrative output of supervisor.py:

Text
[gpt-5-mini-2025-08-07] Findings: global EV sales grew about 25% year over year, with China at roughly 60%...

[gemini-2.5-flash] Growth of 25% on a base of 14M units implies about 3.5M additional vehicles...

[deepseek-chat] Electric vehicle sales are growing quickly, led by China. Battery costs fell 14%...

Using a different model

Change the model string on any agent and install the matching extra. Swapping the supervisor to OpenAI is supervisor_model="gpt-5-mini" with pip install "10xgraph[openai]". Swapping a worker to Gemini is model="gemini-2.5-flash" with pip install "10xgraph[google-genai]".

Next steps

Frequently asked questions

Can agents in the same graph use different LLM providers?
Yes. Each worker or member is a separate Agent with its own model string, so one graph can mix OpenAI, Anthropic, Google and OpenAI-compatible endpoints such as DeepSeek.
What is the difference between SupervisorTeamAgent and SwarmAgent?
In a supervisor team one model decides which worker runs next and control returns to it after each worker. In a swarm the agents hand work directly to each other with transfer_to tools and no central router.
How do I give one agent a custom base_url without affecting the others?
Set base_url and api_key on that Agent when you build it. SupervisorTeamAgent and SwarmAgent take pre-built Agent instances, so settings stay per agent. PlanActReflectAgent shares extra keyword arguments across its phases.
Does PlanActReflectAgent call a model in the act step?
No. ACT is a ToolNode that runs the tools the planner chose. Only PLAN and REFLECT call a model, so plan_model and reflect_model are the two per-phase settings.
Last updated for v1.0.0rc4Edit this page on GitHubReport an issue