Choreography You Can Dial In

We've touched on the four built-in conversation shapes; now you need to make them survive contact with the real world.
In the first post — One Coherent Agent Isn't Enough — you opened a channel, watched agents take turns, and saw the hub fold envelopes into a durable thread. That's the shape. This post is about the dials on top of that shape — the knobs that turn a loose multi-agent free-for-all into something you'd actually run when an agent goes quiet at 2am, a step needs to time out, or a sub-conversation has to stay off the main thread.
If you haven't run the first post's examples yet, start there — every snippet below builds on the same Hub / HubClient / Channel / Adapter primitives.
This is the second post in a four-part series introducing the AG2 Network:
- One Coherent Agent Isn't Enough — the action-driven multi-agent network, the key primitives, and the four conversation shapes.
- Choreography You Can Dial In (this post) — setting expectations, audience addressing, deeper choreography patterns, and the orchestration cookbook.
- What Survives, Survives Exactly — the substrate: write-ahead log + fold + hub restart, plus identity (Passport / Resume / SKILL.md) and the audit log.
- 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, we're preparing a companion post, The Agent Harness: An Agent Is More Than a Loop, that zooms inside a single agent to make them long-running and knowledge-centric. Keep an eye out for that one (we'll update this when it's ready).
Choreography you can dial in#

Pure choreography is loose; full orchestration is rigid. The framework offers control as a dial: pick how much, where, and when.
The four adapters from the first post each sit at a different position on that dial. consulting is the leftmost notch — one question, one answer, hard close. conversation is one click right — two parties, free-form, until someone closes the channel. discussion lives near the middle — a fixed cast taking turns in a deterministic round-robin. workflow is the rightmost notch — a TransitionGraph declared up front that decides who speaks next on every turn. Same primitives end to end; what changes is how much of the routing you write down.
But the starting notch isn't the only dial. Two more sit on top of every adapter, and you reach for them when "what conversation shape do I want?" turns into "when two participants need a side bar, or when the next speaker depends on what the previous one returned?":
- Audience addressing — every envelope carries an
audiencelist, so two participants can open a private side-channel without polluting the main thread. The WAL keeps both cleanly separated. - Deeper TransitionGraph rules —
FromSpeaker,ContextEquals,ToolCalled, and dynamicHandoffreturns let a workflow react to what was said, not just who said it.
The adapters also ship built-in time-based SLAs — expectations that govern how long any step can stall before the hub acts. The next section covers those; the two dials follow.
The next three sections walk through built-in expectations, then each dial, ending with a centerpiece that combines both. Every concept lands as a first-class envelope on the channel's write-ahead log — so the choreography you dial in survives a fold replay, the same way the conversation does.
Built-in expectations#

Every adapter ships built-in time-based SLAs — expectations that fire when a channel stalls. consulting arms two: acks_within(30s, auto_close) (the invitee must ACK within 30 seconds) and reply_within(600s, auto_close) (the respondent must reply within 10 minutes). conversation arms max_silence(3600s, audit) — the hub logs silence but leaves the channel open. The hub evaluates all expectations on a 10-second sweep; the sweep is stateless and re-derives every timer from the WAL, so a hub restart never loses a deadline.
Three evaluators ship today:
| Evaluator | Fires when… |
|---|---|
acks_within | an invitee hasn't ACKed within seconds of the channel invite |
reply_within | the respondent hasn't replied within seconds of the initiator's question |
max_silence | no participant has posted a substantive envelope for seconds |
Three handlers decide what happens when an evaluator fires:
| Handler | Effect |
|---|---|
auto_close | close the channel immediately with reason expectation_violated:<name> |
notify_channel | broadcast ag2.expectation.violated to every participant and stay open |
audit | record to the audit log and do nothing visible |
When the consulting adapter's acks_within(30s, auto_close) fires, the hub closes the channel before open() returns — the close reason expectation_violated:acks_within lands in the WAL and is visible to any listener awaiting EV_CHANNEL_CLOSED. The centerpiece at the end of this post shows the full lifecycle (open → content → close) with both dials live.
Audience addressing and private side-channels#

Every envelope carries an optional audience field — a list of participant IDs that should receive the message. Omit it and the hub broadcasts to everyone. Set it and only those participants see the envelope. The WAL records both variants identically; the audience filter is applied at dispatch time, not write time, so the full log stays durable even if a participant was excluded.
Private side-channels use the same primitive: open a second channel with a restricted participant list. The two channels run on separate WALs that never intersect. An agent on the main channel cannot discover that the side-channel exists, let alone read its messages.
The example below has three participants on a discussion channel. After a public exchange, alice opens a private consulting channel with carol — bob is not invited and never sees that conversation.
Both WALs are first-class objects on the hub. Bob can replay the main channel from start to finish — carol's private consultation with alice never appears there. From the hub's point of view, two channels ran in parallel, each with its own participant list, its own adapter, and its own set of expectations.
Deeper TransitionGraph#

TransitionGraph.sequence([a, b, c]) from the first post is the simplest possible graph — a straight line. For real workflows you'll want two patterns beyond that.
Dynamic Handoff — routing by return value, not graph edge. Any @tool on a workflow agent can return a Handoff instance. The framework extracts the target from the tool's result and overrides the graph rule for that turn. This means the drafter doesn't need a separate ToolCalled("classify") edge for every possible specialist — it calls one tool and lets the return value decide:
When the classify() tool returns Handoff(target=specialist_id), the fold sets the next speaker as the specialist_id. The graph edge is never consulted.
ContextEquals — routing on state, not sender. ContextEquals(key, value) fires when channel.context_vars[key] == value. You write context vars from a @tool via set_context(channel, key, value). Similar to a tool returning a Handoff, this is evaluated before evaluating transitions, so the same turn that sets a var can immediately route on it:
Two things to notice. First, ContextEquals("done", True) is listed before ToolCalled("review_draft") — transitions are walked in list order (with equal priority, list position breaks ties), so termination fires before the loop-back. Second, the ToolCalled("review_draft") entry does double duty: it makes the adapter post an EV_PACKET even when the reviewer calls the tool but writes no text, so the fold always runs and the ContextEquals check always gets evaluated. Without it, an empty-body turn silently disappears and the graph never advances.
Centerpiece runnable — both dials at once#

The final example combines both dials on a single workflow channel:
- Dynamic Handoff — the drafter calls
classify()which returns aHandoffpointing at the specialist. The graph doesn't need a static edge for every possible specialist. - ContextEquals routing — the specialist calls
flag(status='complete')which writesstatusto the channel'scontext_vars. AContextEquals("status", "complete")transition fires on the specialist's round-end packet and terminates the workflow cleanly.
The WAL from a real run looks like this:
user ag2.channel.invite (× 2 — drafter, specialist)
drafter ag2.channel.invite.ack
specialist ag2.channel.invite.ack
user ag2.channel.opened
user EV_TEXT : Brief: explain the security implications of DNS-over-HTTPS…
drafter EV_PACKET : routing=handoff body=Routing to specialist: complex topic…
specialist EV_CONTEXT_SET : {'status': 'complete'}
specialist EV_PACKET : routing=terminate:specialist_done body=## Security Implications of DoH…
user ag2.channel.closed reason=specialist_done
Every dial lands exactly once, in the right place, in the right order. The EV_CONTEXT_SET is the specialist writing to the channel's state; the EV_PACKET that follows is the turn-end envelope whose fold evaluates ContextEquals("status", "complete") and terminates the workflow. The EV_CHANNEL_CLOSED carries reason=specialist_done — clean, deterministic, no stall required.
Wrapping up#
Two dials, one primitive. Audience addressing governs visibility — who sees which envelope on which thread. TransitionGraph conditions govern routing — who speaks next based on what was said or what state was written. The built-in expectations govern time automatically: every adapter ships SLAs that fire without any configuration. All three are first-class envelopes on the WAL, which means everything you dial in survives a hub restart exactly as written.
The next post in this series — What Survives, Survives Exactly — zooms into that WAL. You'll see how the hub re-derives every channel from scratch on restart, how identity (Passport / Resume / SKILL.md) and the audit log compose with the same fold model, and what "durable" really means for a multi-agent conversation.