# One Coherent Agent Isn't Enough — Action-Driven Networking with AG2

A single agent is a great starting point, but real work extends beyond just one.

Real work spans people, teams, services, and machines. A support escalation touches a triage bot, a knowledge agent, an on-call engineer, and a postmortem writer. None of them is "in charge" — they each take a turn, in the open, over a shared thread that outlives any one of them.

That's what the **AG2 Network** is built for: a layer where stateful, identity-bound, choreographed _actions_ live. By the end of this post you'll have run all four conversation shapes the network ships with — and have a flavor for AG2's new multi-agent network, which we'll expand on in upcoming posts.

This is the first post in a four-part series introducing the AG2 Network:

1. **One Coherent Agent Isn't Enough** _(this post)_ — the action-driven multi-agent network, the key primitives, and the four conversation shapes.
2. [**Choreography You Can Dial In**](https://docs.ag2.ai/docs/blog/2026/05/15/AG2-Network-Choreography-Dial/) — setting expectations, audience addressing, deeper choreography patterns, and the orchestration cookbook.
3. [**What Survives, Survives Exactly**](https://docs.ag2.ai/docs/blog/2026/05/16/AG2-Network-What-Survives/) — the substrate: write-ahead log + fold + hub restart, plus identity (Passport / Resume / SKILL.md) and the audit log.
4. **Networks You Can Deploy** — federation across organizations (Visa), dynamic register / unregister, omni-modal streaming, and a full production-incident walk-through.

In parallel, to build strong and capable agents, a companion post, **The Agent Harness: An Agent Is More Than a Loop**, zooms _inside_ a single agent to make them long-running and knowledge-centric.

## One Coherent Agent Isn't Enough

The instinct when you have many agents is to add a _coordinator_: one agent (or one Python loop) that decides who runs next, collects the output, and decides again. That works in the demo. Then it has to ship, and the coordinator turns out to be a bottleneck, a single point of failure, and the place where all the state lives in memory until the process restarts and the conversation is gone.

The deeper issue: **the internet wasn't drawn for this either**. HTTP, REST, MCP, A2A — they all work because the _client_ has memory, in its head or its harness. A single agent talking to tools through MCP fits that mold perfectly. But two agents collaborating across processes don't. Both ends are stateless. The state has to live somewhere — and it can't live in either harness, because neither side can see the whole conversation alone.

The natural home for that state is **the channel itself** — a durable, addressable thread that participants take turns writing to. Nobody on either end "owns" it. The hub that hosts the channel is a thin, stateless router: it stamps every message, appends it to the channel's log, fans it out. Restart the hub and it re-derives every channel from disk, byte for byte.

That gives the network a fundamentally different _shape_ from the request-response world we inherited.

> _The harness above each agent doesn't scale across processes. The natural home is the channel itself._

## The Four Primitives

AG2's Network introduces primitives to support all levels of multi-agent engagements. We'll explore them over the next few blogs, but let's start with four key primitives to get you up and running.

- **Hub** — the only authoritative state. Registry of agents, write-ahead log per channel, audit log, the adapter registry. Stateless in the sense that the _application_ puts no logic here — the hub just routes envelopes and folds them. Crash it, restart it: state is re-derived from disk.
- **HubClient** — one duplex connection to the hub per process boundary. In a real deployment each agent lives in its own process with one `HubClient`. Locally they can share a process for clarity.
- **Channel** — the unit of conversation. Durable, addressable by ID, identity-scoped. Lifecycle: `INVITED → ACTIVE → CLOSING → CLOSED`. Every channel is governed by exactly one **adapter**.
- **Adapter** — bound one-to-one with a channel, the adapter is the key orchestration definition. Stateless code that decides "what's allowed next" — _who can speak_, _when does this auto-close_. Four adapters ship; you'll meet all four below. Adapter state is a pure fold of the channel's WAL, which is why a hub restart is a non-event.

A `Passport` (an agent's identity record) and an `Envelope` (the wire message) slip through every example below. When you see `Passport(name="alice")`, that's an immutable identity stamp the hub will assign an `agent_id` to; when you see `EV_TEXT` or `EV_CHANNEL_CLOSED`, those are envelope event types riding on the channel's WAL.

## Four Shapes of Orchestration

Adapters lay the foundation for you to choose, or create, the orchestration that works for your task. Opening a channel with an adapter sets the **protocol your agents must be orchestrated by.**

We've started with four adapters. As noted, they're derived from a protocol and you can roll your own.

| Adapter | Shape | Turn order | Closes |
| --- | --- | --- | --- |
| `consulting` | 1 ↔ 1, one round | ask → answer | auto-closes after the reply |
| `conversation` | 1 ↔ 1, free-form | none | explicit `close()` or TTL |
| `discussion` | N participants | round-robin | explicit `close()` or TTL |
| `workflow` | N participants | a declared `TransitionGraph` | when the graph terminates |

Note: The `workflow` adapter correlates closely with AG2's classic group chat with handoffs.

Let's run through each one.

### `consulting` — 1Q1R, the smallest viable network

The simplest possible network: one agent asks another a single question, gets a single answer, and the channel auto-closes.

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>25<br>26<br>27<br>28<br>29<br>30<br>31<br>32<br>33<br>34<br>35<br>36<br>37<br>38<br>39<br>40<br>41<br>42<br>43<br>44<br>45<br>46<br>47<br>48<br>49<br>50<br>51<br>52<br>53<br>54<br>55<br>56<br>57<br>58<br>59<br>60<br>61<br>62<br>63<br>``` | ```<br>import asyncio<br>from autogen.beta import Agent<br>from autogen.beta.config import AnthropicConfig<br>from autogen.beta.knowledge import MemoryKnowledgeStore<br>from autogen.beta.network import (<br>    EV_CHANNEL_CLOSED,<br>    EV_TEXT,<br>    Hub,<br>    HubClient,<br>    LocalLink,<br>    Passport,<br>    Resume,<br>)<br>async def main() -> None:<br>    config = AnthropicConfig(model="claude-sonnet-4-6")<br>    # Hub: registry + WAL + audit log + adapters live here.<br>    hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0)<br>    link = LocalLink(hub)  # in-process duplex transport<br>    # One HubClient per process boundary. Locally they share a process.<br>    alice_hc = HubClient(link, hub=hub)<br>    bob_hc = HubClient(link, hub=hub)<br>    alice = await alice_hc.register(<br>        Agent("alice", prompt="Ask one focused question and stop.", config=config),<br>        Passport(name="alice"),<br>        Resume(),<br>    )<br>    bob = await bob_hc.register(<br>        Agent("bob", prompt="Answer in one short sentence.", config=config),<br>        Passport(name="bob"),<br>        Resume(),<br>    )<br>    # Strict 1Q1R; the adapter auto-closes on bob's reply.<br>    channel = await alice.open(type="consulting", target="bob")<br>    await channel.send(<br>        "What's the single most important property of a distributed system?",
        audience=[bob.agent_id],<br>    )<br>    # Wait for the consulting round to close.<br>    close_env = await alice.wait_for_channel_event(<br>        channel_id=channel.channel_id,<br>        predicate=lambda e: e.event_type == EV_CHANNEL_CLOSED,<br>        timeout=60.0,<br>    )<br>    print(f"closed: {close_env.event_data.get('reason')!r}")<br>    # Replay the conversation from the channel's write-ahead log.<br>    for env in await hub.read_wal(channel.channel_id):<br>        if env.event_type == EV_TEXT:<br>            speaker = "alice" if env.sender_id == alice.agent_id else "bob"<br>            print(f"{speaker}: {env.event_data['text']}")<br>    await alice_hc.close()<br>    await bob_hc.close()<br>    await hub.close()<br>asyncio.run(main())<br>``` |

Output example:

```
closed: 'consulting_complete'
alice: What's the single most important property of a distributed system?
bob: Fault tolerance — because a system that can't survive partial failures defeats its entire purpose.
```

Reach for `consulting` when you want a tool-like agent call: a single specialist invocation with a structured boundary. See [Consulting Adapter](https://docs.ag2.ai/docs/beta/network/consulting/) for the full surface.

### `conversation` — 1:1 free-form

A 2-party channel with no turn ordering. Either side can speak. Useful for an agent + a human, or two agents in a back-and-forth.

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>25<br>26<br>27<br>28<br>29<br>30<br>31<br>32<br>33<br>34<br>35<br>36<br>37<br>38<br>39<br>40<br>41<br>42<br>43<br>44<br>45<br>46<br>47<br>48<br>49<br>50<br>51<br>52<br>53<br>54<br>55<br>56<br>57<br>58<br>``` | ```<br>import asyncio<br>from autogen.beta import Agent<br>from autogen.beta.config import AnthropicConfig<br>from autogen.beta.knowledge import MemoryKnowledgeStore<br>from autogen.beta.network import (<br>    EV_TEXT,<br>    Hub,<br>    HubClient,<br>    LocalLink,<br>    Passport,<br>    Resume,<br>)<br>async def wait_for_text_count(hub: Hub, channel_id: str, expected: int) -> None:<br>    """Tail the channel's WAL; return after `expected` EV_TEXT envelopes.<br>    Used to close the channel when we reach a certain number of messages."""<br>    seen: set[str] = set()<br>    count = 0<br>    while count < expected:<br>        for env in await hub.read_wal(channel_id):<br>            if env.envelope_id in seen:<br>                continue<br>            seen.add(env.envelope_id)<br>            if env.event_type == EV_TEXT:<br>                count += 1<br>        await asyncio.sleep(0.05)<br>async def main() -> None:<br>    config = AnthropicConfig(model="claude-sonnet-4-6")<br>    hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0)<br>    link = LocalLink(hub)<br>    alice_hc = HubClient(link, hub=hub)<br>    bob_hc = HubClient(link, hub=hub)<br>    alice = await alice_hc.register(<br>        Agent("alice", prompt="You are alice. Ask follow-ups, one short sentence at a time.", config=config),<br>        Passport(name="alice"), Resume(),<br>    )<br>    bob = await bob_hc.register(<br>        Agent("bob", prompt="You are bob. Answer in one short sentence; ask one follow-up.", config=config),<br>        Passport(name="bob"), Resume(),<br>    )<br>    channel = await alice.open(type="conversation", target="bob")<br>    await channel.send("How would you explain consensus to a curious novice?")<br>    # Let them go four turns, then close from the application side.<br>    await wait_for_text_count(hub, channel.channel_id, expected=4)<br>    await channel.close()<br>    for env in await hub.read_wal(channel.channel_id):<br>        if env.event_type == EV_TEXT:<br>            speaker = "alice" if env.sender_id == alice.agent_id else "bob"<br>            print(f"{speaker}: {env.event_data['text']}")<br>    await alice_hc.close(); await bob_hc.close(); await hub.close()<br>``` |

`conversation` doesn't auto-terminate. The application decides when the work is done. See [Conversation Adapter](https://docs.ag2.ai/docs/beta/network/conversation/).

### `discussion` — N-party round-robin

Three agents — an optimist, a realist, a skeptic — debate a topic, round-robin, with no coordinator deciding turns. The adapter enforces the order; each agent's default handler skips the LLM call entirely when it's not its turn (`hc.can_send` returns false).

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>25<br>26<br>27<br>28<br>29<br>30<br>31<br>32<br>33<br>34<br>35<br>36<br>37<br>38<br>39<br>40<br>41<br>42<br>43<br>44<br>45<br>46<br>47<br>48<br>49<br>50<br>51<br>52<br>53<br>54<br>55<br>56<br>57<br>58<br>59<br>60<br>61<br>62<br>63<br>64<br>65<br>``` | ```<br>import asyncio<br>from autogen.beta import Agent<br>from autogen.beta.config import AnthropicConfig<br>from autogen.beta.knowledge import MemoryKnowledgeStore<br>from autogen.beta.network import (<br>    EV_TEXT,<br>    ORDERING_ROUND_ROBIN,<br>    Hub, HubClient, LocalLink, Passport, Resume,<br>)<br>async def wait_for_text_count(hub: Hub, channel_id: str, expected: int) -> None:<br>    """Tail the channel's WAL; return after `expected` EV_TEXT envelopes.<br>    Used to close the channel when we reach a certain number of messages."""<br>    seen: set[str] = set()<br>    count = 0<br>    while count < expected:<br>        for env in await hub.read_wal(channel_id):<br>            if env.envelope_id in seen:<br>                continue<br>            seen.add(env.envelope_id)<br>            if env.event_type == EV_TEXT:<br>                count += 1<br>        await asyncio.sleep(0.05)<br>async def main() -> None:<br>    config = AnthropicConfig(model="claude-sonnet-4-6")<br>    hub = await Hub.open(MemoryKnowledgeStore(), ttl_sweep_interval=0)<br>    link = LocalLink(hub)<br>    alice_hc, bob_hc, carol_hc = (HubClient(link, hub=hub) for _ in range(3))<br>    alice = await alice_hc.register(<br>        Agent("alice", prompt="You are the optimist. One short sentence.", config=config),<br>        Passport(name="alice"), Resume(),<br>    )<br>    bob = await bob_hc.register(<br>        Agent("bob", prompt="You are the realist. One short sentence.", config=config),<br>        Passport(name="bob"), Resume(),<br>    )<br>    carol = await carol_hc.register(<br>        Agent("carol", prompt="You are the skeptic. One short sentence.", config=config),<br>        Passport(name="carol"), Resume(),<br>    )<br>    channel = await alice.open(<br>        type="discussion",<br>        target=[bob.agent_id, carol.agent_id],<br>        knobs={"ordering": ORDERING_ROUND_ROBIN},<br>    )<br>    await channel.send("Topic: should every developer learn Rust? Keep it brief.")<br>    # 6 messages = two full round-robin cycles. Then close to halt the chain.<br>    await wait_for_text_count(hub, channel.channel_id, expected=6)<br>    await channel.close()<br>    names = {alice.agent_id: "alice", bob.agent_id: "bob", carol.agent_id: "carol"}<br>    for env in await hub.read_wal(channel.channel_id):<br>        if env.event_type == EV_TEXT:<br>            print(f"{names[env.sender_id]:>6}: {env.event_data['text']}")<br>    await alice_hc.close(); await bob_hc.close(); await carol_hc.close()<br>    await hub.close()<br>asyncio.run(main())<br>``` |

Reach for `discussion` when you want a fixed cast taking turns — brainstorms, panel reviews, devil's-advocate debates. See [Discussion Adapter](https://docs.ag2.ai/docs/beta/network/discussion/).

### `workflow` — graph-driven

`workflow` is the most expressive adapter: speaker order is governed by a declarative `TransitionGraph` you pass in at channel-open together with handoffs from tools. Two convenience factories ship for the simple cases — `TransitionGraph.sequence([...])` for a linear pipeline, and `TransitionGraph.round_robin([...], max_turns=N)` for a hard-capped rotation — but the real power is in **conditional handoffs**: each `Transition` says _"when this condition fires, hand off to that target."

In this example: researcher gathers facts, writer drafts, reviewer either approves or kicks the draft back to the writer. The reviewer's two routing tools illustrate the **two ways** workflow lets you drive routing:

- **Dynamic** — `request_revision` returns `Handoff(target="writer", reason=...)`. The tool _itself_ picks the next speaker at call time. No graph rule needed; a returned `Handoff` supersedes any matching `ToolCalled` rule. Use this when the target depends on runtime state (load balancing, content-driven dispatch, "ask whichever specialist is registered for X").
- **Static** — `approve` returns a plain string and is matched by `ToolCalled("approve")` → `TerminateTarget("approved")`. The graph owns the decision. Use this when routing is fixed at channel-open time.

|     |     |
| --- | --- |
| ```<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>25<br>26<br>27<br>28<br>29<br>30<br>31<br>32<br>33<br>34<br>35<br>36<br>37<br>38<br>39<br>40<br>41<br>42<br>43<br>44<br>45<br>46<br>47<br>48<br>49<br>50<br>51<br>52<br>53<br>54<br>55<br>56<br>57<br>58<br>59<br>60<br>61<br>62<br>63<br>64<br>65<br>``` | ```<br>from autogen.beta import Agent, tool<br>from autogen.beta.network import (<br>    AgentTarget, FromSpeaker, Handoff, TerminateTarget,<br>    ToolCalled, Transition, TransitionGraph,<br>)<br># ... imports + hub setup as above ...<br>@tool<br>def request_revision(reason: str) -> Handoff:<br>    """Send the draft back to the writer for revision."""<br>    return Handoff(target="writer", reason=reason)<br>@tool<br>def approve() -> str:<br>    """Approve the draft and end the workflow."""<br>    return "Approved."<br>researcher = await researcher_hc.register(<br>    Agent("researcher", prompt="Given a topic, list 3 concrete factual bullets.", config=config),<br>    Passport(name="researcher"), Resume(claimed_capabilities=["research"]),<br>)<br>writer = await writer_hc.register(<br>    Agent("writer", prompt="Given research bullets, draft a 2-sentence explanation for a novice.", config=config),<br>    Passport(name="writer"), Resume(claimed_capabilities=["writing"]),<br>)<br>reviewer = await reviewer_hc.register(<br>    Agent(<br>        "reviewer",<br>        prompt=(<br>            "You are a strict-but-fair reviewer. "<br>            "On the FIRST draft, always call request_revision with one concrete suggestion. "<br>            "On the REVISED draft, always call approve(). "<br>            "Exactly one revision round, then approve."<br>        ),<br>        config=config,<br>        tools=[request_revision, approve],<br>    ),<br>    Passport(name="reviewer"), Resume(claimed_capabilities=["reviewing"]),<br>)<br># Conditional graph — `request_revision` doesn't appear here: it routes<br># itself via the Handoff it returns. The graph only owns the static rules:<br># the terminator and the default forward flow.<br>graph = TransitionGraph(<br>    initial_speaker=researcher.agent_id,<br>    transitions=[<br>        Transition(when=ToolCalled("approve"),            then=TerminateTarget("approved")),<br>        Transition(when=FromSpeaker(researcher.agent_id), then=AgentTarget(writer.agent_id)),<br>        Transition(when=FromSpeaker(writer.agent_id),     then=AgentTarget(reviewer.agent_id)),<br>    ],<br>)<br>channel = await researcher.open(<br>    type="workflow",<br>    target=[writer.agent_id, reviewer.agent_id],<br>    knobs={"graph": graph.to_dict()},<br>)<br>await channel.send("Topic: how does HTTPS keep traffic private?")<br># Auto-terminates with reason="approved" once the reviewer is happy.<br>await researcher.wait_for_channel_event(<br>    channel_id=channel.channel_id,<br>    predicate=lambda e: e.event_type == EV_CHANNEL_CLOSED,<br>    timeout=180.0,<br>)<br>``` |

Note that `Handoff(target="writer", ...)` uses the participant's `Passport.name` rather than its `agent_id` — tool code stays decoupled from runtime IDs and portable across channels.

Reach for `workflow` when "who speaks next" is structured — pipelines, escalation, conditional routing. See [Workflow Adapter](https://docs.ag2.ai/0.13.2/docs/beta/network/workflow/).

## The Bigger Picture

You've now run all four conversation shapes. There are three more layers the network adds on top, each covered in an upcoming post:

- **A choreography dial** — same primitive, more knobs. _Expectations_ like `reply_within(30s, auto_close)` give the channel a deterministic response when a participant misses a deadline. _Audience_ on each envelope (`audience=[a, b]`) lets a single channel carry private side-channels for free. Deeper `TransitionGraph` patterns — conditional handoffs, dynamic `Handoff` returns, context-aware routing — sit alongside. **Post 2: [Choreography You Can Dial In](https://docs.ag2.ai/docs/blog/2026/05/15/AG2-Network-Choreography-Dial/).**
- **A trustworthy substrate** — what's written down and who you are. The hub's WAL is the source of truth: kill the hub, restart it, every channel comes back byte-for-byte. Identity is three records (`Passport` \+ `Resume` \+ `SKILL.md`), not a name string. The audit log records every envelope. **Post 3: [What Survives, Survives Exactly](https://docs.ag2.ai/docs/blog/2026/05/16/AG2-Network-What-Survives/).**
- **Cross-boundary deployment** — what lets you actually ship this. A single channel can span two organizations through Passport + Visa. Participants register and unregister dynamically. Text, audio, image, and video ride the same fan-out. And a real production-incident demo ties it all together. **Post 4: Networks You Can Deploy (coming soon)**

## Interactive Playground

Jump into [AG2's Playground](https://playground.ag2.ai/) to take an interactive tour of the AG2 Network and run Beta examples live.
