AG2 Network — Tools, Views & Custom Handlers
Everything on the agent/client side of the network — the mirror image of ag2-network-governance (which is hub-side). This covers what the LLM sees of the channel (views), which actions it can take (the five auto-injected plugin tools plus the adapter-owned say), what other agents know about it (skill markdown), how to replace the handler entirely, and the full Envelope reference.
Prerequisite: read
ag2-network-quickstartfirst. This skill assumes you knowHub.open,HubClient.register, the channel lifecycle, and basicagent_client.open(...)/channel.send(...).
When to use
- "Limit / extend the LLM's network tool surface" (plugin tools vs. adapter
tools_for) - "Write a custom envelope handler"
- "Build a gateway / headless worker that doesn't run an LLM"
- "Add a non-LLM participant — a person at a UI, a bridge, a scripted seeder" (
HumanClient) - "Customise what each agent sees of the channel history (view policy)"
- "Strip / redact / filter envelopes before they reach the LLM"
- "Wire peer discovery via skill markdown"
- "Send a custom event type (
myapp.review_request, …)" - "I need the
EV_*constants list /Envelopeshape /audiencesemantics"
Network tools — plugin tools vs. adapter tools
An agent's per-turn LLM tool list is assembled from two streams:
- Plugin tools — when you register with the default
attach_plugin=True,NetworkPluginadds five identity-level, channel-agnostic verbs toagent.tools:peers/channels/tasks/context/delegate. Same behaviour in any channel. - Adapter tools — the channel's adapter offers channel-specific tools per turn via
adapter.tools_for(client, metadata, state, participant_id); the default handler resolves them and merges them intoagent.ask(tools=...)(cached per(adapter, client)so the schema build cost is paid once). The only built-in adapter tool issay.
So say is not a plugin tool — it's offered by the adapter, gated by turn state:
| Adapter | Offers say to… |
|---|---|
consulting |
the participant whose turn it is in the 1Q1R (initiator before the prompt; respondent after, before the reply) |
conversation |
every participant, always |
discussion |
only expected_next_speaker |
workflow |
nobody — routing is your @tool handoff functions; the adapter returns [] |
That's why attach_plugin=False no longer controls whether the LLM sees say — it controls the five plugin verbs only. (See ag2-network-quickstart → "Plugin tools vs. adapter tools" for the user-facing summary.)
delegate and say
| Tool | Stream | Signature | Purpose |
|---|---|---|---|
delegate |
plugin | delegate(target, prompt, capability?, timeout=300.0) |
One-shot consult — opens a consulting channel with target, sends prompt, awaits the single reply, returns its text. A separate channel, so it's safe to call mid-turn on any channel. |
say |
adapter (consulting / conversation / discussion) |
say(content, audience?, channel_id?) |
Post an EV_TEXT into the active channel (or a specified one the agent participates in). audience is a list of peer names (resolved to ids); None broadcasts. Envelope shape comes from adapter.build_text_envelope(...) — the same Layer-2 helper a non-AG2 bridge would call. |
# The LLM emits, e.g.:
delegate(target="bob", prompt="What's the right way to model X?", capability="modeling")
say(content="Here's my answer: …")
The framework resolves ChannelInject (current channel), ChannelStateInject, and AgentClientInject automatically inside the notify handler — the LLM never sees those parameters.
Four grouped action-dispatch tools
Each takes an action literal plus action-specific args, keeping the LLM's tool list short.
peers(action) — discovery
| Action | Args | Returns |
|---|---|---|
"find" |
query?, capability?, sort_by?, limit=20 |
List of peer summaries (excludes the caller). |
"describe" |
name |
One peer's full profile: {passport, resume, skill_md}. skill_md falls back to a rendered passport+resume when no SKILL.md is registered. |
channels(action) — lifecycle
| Action | Args | Returns |
|---|---|---|
"list" |
state="active"|"all" |
Channels this agent participates in. |
"open" |
type, target, knobs?, intent?, ttl?, message? |
Mirrors agent_client.open. If message is given, it's sent as the first envelope on the initiator's behalf right after the channel reaches OPENED (handy for consulting/workflow seeding); a failed seed closes the channel with reason seed_failed. Returns {channel_id, type, participants[, seed_envelope_id]}. |
"info" |
channel_id |
Full ChannelMetadata if the agent participates. |
"close" |
channel_id? (defaults to current) |
Closes with reason "closed_by_agent". |
tasks(action) — task lifecycle
Two halves: active actions (the agent is inside its own agent.task(...) block) and observation actions (any task the hub has seen).
| Action | Half | Args | Returns |
|---|---|---|---|
"progress" |
active | payload |
Emits TaskProgress. |
"complete" |
active | result? |
Terminal — emits TaskCompleted. |
"list" |
observation | scope="own"|"all", state="active"|"all", limit=20 |
Task summaries. |
"status" |
observation | task_id |
Refreshed TaskMetadata. |
"wait" |
observation | task_id, timeout=300, poll_interval=0.1 |
Blocks until terminal. |
"start" is intentionally not a tool — calling it from the LLM would bypass the async with agent.task(...) lifecycle that scopes TaskInject correctly. Owners start tasks in their own code; the LLM uses progress / complete once a task is active, and delegate for one-shot remote work.
context(action) — past content
| Action | Args | Returns |
|---|---|---|
"search" |
query, scope="channel"|"knowledge", limit=10 |
Excerpts whose text matches query (case-insensitive substring). |
"quote" |
speaker, recent_n=1, channel_id? |
The last recent_n EV_TEXT envelopes from speaker. |
scope="knowledge" reaches into the calling agent's own KnowledgeStore (substring search only — for vector / semantic search, the agent's own loop calls framework-core recall directly).
Adapter-owned tools (tools_for) — and the say double-send
The default handler resolves adapter.tools_for(client, metadata, state, participant_id) each turn and merges the result into agent.ask(tools=...) (alongside the agent's own @tools and any plugin tools). Adapters that take no LLM input — workflow — return []. The others return say, gated by turn state (table above). An adapter could return richer tools too; say is just the only built-in.
Implication for workflow: a workflow agent never sees say — its only path to "say something" is the round-end EV_PACKET the handler builds from reply.body, plus whatever @tool handoff functions you wrote. The old "approve() then say(...) races the round-end EV_PACKET" failure mode is structurally impossible now.
For consulting / conversation / discussion, say is offered — but you rarely need it. The default handler already posts the round-end envelope (build_round_envelope → EV_TEXT(reply.body), or None if empty), so an agent that just replies with text has already spoken. say is for posting an extra message in a turn, or posting into a different channel the agent participates in (channel_id=).
The hazard: an agent that calls say(content="…") and then also returns a non-empty reply body emits two substantive envelopes in one turn. On conversation that's harmless. On consulting the second one trips the strict 1Q1R adapter — ProtocolError: channel '<id>' is closed. Mitigations, most robust first:
- Don't prompt the agent to call
say. Its plain reply is the canonical channel reply; the double-send only happens if you explicitly steer it towardsay. - Replace the default handler with one that doesn't also send a round-end envelope — then
sayis the agent's voice (gateway / headless-worker patterns below). attach_plugin=Falsedoes not help here —sayis adapter-owned, not plugin-owned. It only dropsdelegate/peers/channels/tasks/context. Use it for a bare agent, not as asaysuppressor.
Non-LLM participants — HumanClient
HumanClient is the framework's first-class "participant that isn't an Agent": no LLM, no NetworkPlugin, no assembly policies. A person at a UI, a bridge to another system, a scripted "user" that seeds a workflow — all of these are a HumanClient. Register with register_human (not register, which now rejects kind="human" and points you here):
from ag2.network import HumanClient, Passport
user = await hc.register_human(Passport(name="user", kind="human")) # resume=, rule=, auto_ack_invites= optional
It satisfies the NetworkClient Protocol — same outbound surface as AgentClient:
ch = await user.open(type="consulting", target="analyst") # initiate a channel
await user.send(ch.channel_id, "What's our Q3 exposure?") # EV_TEXT convenience
await user.post_envelope(env) # escape hatch — adapter-shaped envelopes (e.g. workflow EV_PACKET via adapter.build_packet_envelope)
…plus two ways to consume inbound envelopes (an AgentClient only has the notify-handler callback; HumanClient adds an explicit queue):
| Surface | Call | Semantics |
|---|---|---|
| Push | user.on_envelope(callback) |
async callback fires once per inbound envelope; multiple callbacks compose in registration order; a raising callback is logged, never propagated. remove_envelope_callback(cb) to detach. |
| Pull | await user.next_envelope(*, predicate=None, timeout=None) |
Blocks until the next envelope matching predicate (or any, if None); raises on timeout. |
| Pull (stream) | async for env in user.envelopes(): ... |
Yields every inbound envelope until user.disconnect(). |
auto_ack_invites=True (default) makes the human auto-accept channel invites so the hub's quorum handshake completes without a UI round-trip; pass auto_ack_invites=False to gate joins by hand (you'll then post_envelope an EV_CHANNEL_INVITE_ACK yourself). hub.list_agents(kind="human") discovers humans; hub.list_agents(kind="agent") / kind="remote_agent" filter the others.
Typical roles: the kickoff seeder for a workflow channel (FromSpeaker(user) → AgentTarget(first_agent) — see ag2-network-workflow), the human leg of a consulting Q&A, a participant in a discussion round-robin, or a WebSocket/CLI bridge in front of any of those. For a headless agent (an AgentClient that shouldn't run an LLM but still wants the plugin tools / tools_for resolution) you replace its notify handler instead — next.
Replacing the default handler
The default handler does all the "agent receives envelope → auto-ack invites → run LLM (with adapter.tools_for(...) merged in) → post round-end envelope" wiring. Replace it for headless workers, gateways, or any agent that shouldn't run an LLM. (For a participant that was never meant to have an LLM at all, reach for HumanClient above instead of an AgentClient with a custom handler.)
Opting out of the plugin (and/or the default handler)
worker = await hc.register(agent, passport, resume, attach_plugin=False) # no peers/channels/tasks/context/delegate
worker.on_envelope(my_custom_handler) # ← this is what swaps the handler
These are two independent knobs:
attach_plugin=FalseskipsNetworkPlugin.register(agent)— the agent's tool list losespeers/channels/tasks/context/delegate(and theNetworkContextPolicyprefix). It does not touch the notify handler, and it does not removesay(that's adapter-owned — see above).- The default notify handler is active regardless (it's wired by
AgentClient, not the plugin — unless you registered withattach_default_handler=False). Callclient.on_envelope(callback)to replace it; callclient.on_envelope(client._run_default_handler)to restore it.
What you lose when you call client.on_envelope(callback):
| The default handler does… | If you don't replicate it… |
|---|---|
Auto-ack EV_CHANNEL_INVITE (post EV_CHANNEL_INVITE_ACK) |
the channel sits in PENDING until invite_ack_timeout (30s) and the hub closes it (reason invite_timeout) on you |
Run _process_substantive on EV_TEXT / EV_PACKET: read WAL, project the view, stamp dependencies, attach TaskMirror, resolve adapter.tools_for(...), call agent.ask(...), post the round-end envelope built by adapter.build_round_envelope |
the LLM never runs, no reply ever goes back, the channel stalls until an expectation fires |
No-op on ag2.channel.* / ag2.task.* lifecycle envelopes |
(harmless to skip, but easy to be surprised when these arrive) |
The cheap way to keep most of that for free: handle the events you care about yourself, delegate the rest to default_handler.
Handler signature. A callback passed to
client.on_envelope(...)is called with just(envelope,)— the framework doesawait self._on_envelope(envelope). Anyclientreference inside the handler is captured from the enclosing scope (a closure variable). The exporteddefault_handler, by contrast, has signature(envelope, client)— pass both when delegating.
A gateway handler
from ag2.network import Envelope, EV_TEXT, default_handler
async def gateway_handler(envelope: Envelope) -> None:
"""Forward inbound text to an external system instead of running an LLM."""
if envelope.event_type != EV_TEXT:
await default_handler(envelope, client) # auto-ack invites etc. (client from closure)
return
text = envelope.event_data.get("text", "")
await my_external_queue.put({
"from": envelope.sender_id,
"text": text,
"channel": envelope.channel_id,
})
client.on_envelope(gateway_handler)
Selective override (fall back to default)
from ag2.network import default_handler, EV_CHANNEL_INVITE
async def selective_handler(envelope: Envelope) -> None:
if envelope.event_type == EV_CHANNEL_INVITE and envelope.sender_id not in TRUSTED_AGENTS:
return # untrusted invite → silent drop; hub will time out and close
await default_handler(envelope, client) # default for everything else
client.on_envelope(selective_handler)
Filtered forwarding (pre/post hooks)
async def logged_handler(envelope: Envelope) -> None:
log.info("inbound %s from %s", envelope.event_type, envelope.sender_id)
try:
await default_handler(envelope, client)
finally:
log.info("processed %s", envelope.envelope_id)
Bypassing adapter tools
Adapters offer per-turn tools via tools_for(...) (notably say on consulting / conversation / discussion); the default handler merges them into agent.ask(tools=...). If a capable model insists on calling say unprompted (Claude often will) and the resulting double-send is breaking your channel, swap in a custom handler that runs the same substantive path but omits tools= from agent.ask — the agent's static agent.tools (your @tools + any plugin tools) remain, since ask(tools=…) is additional tools, not a replacement. The shape:
async def no_say_handler(envelope, client):
if envelope.event_type != EV_TEXT:
await default_handler(envelope, client) # invite-ack + lifecycle for free
return
if not await client._hub_client.can_send(envelope.channel_id, client.agent_id):
return # not our turn / channel closing
# ... read_wal_until / resolve_view_policy / stamp_dependencies (hooks above) ...
reply = await client.agent.ask(current_input, stream=stream, dependencies=deps) # ← no tools=
out = adapter.build_round_envelope(metadata=meta, sender_id=client.agent_id, reply=reply,
events=events, state=state, hub=client._hub)
if out is not None:
out.causation_id = envelope.envelope_id
await client.send_envelope(out)
That's the default handler's substantive path minus one line. Same trick works to add tools (pass your own tools=[...]) or to swap the view, the dependencies, the round-envelope shape, etc.
Hooks for selective override
If you want to partially replace the default handler's logic, the handler is decomposed into public hooks:
from ag2.network import (
read_wal_until,
resolve_view_policy,
stamp_dependencies,
)
| Hook | Purpose |
|---|---|
read_wal_until(client, envelope) |
Slice the WAL up to (excluding) the given envelope. |
resolve_view_policy(client, metadata) |
The ViewPolicy this participant should use. |
stamp_dependencies(client, channel, state) |
Build the context.dependencies dict for the LLM turn (CHANNEL_DEP, AGENT_CLIENT_DEP, HUB_DEP, CHANNEL_STATE_DEP). state is the adapter's current State object (WorkflowState / DiscussionState / …), folded once per turn. |
Use these when your custom handler wants the standard pre-LLM wiring but a custom post-LLM behaviour (or vice versa).
Views — what each LLM sees
ViewPolicy is the projection layer between the channel's WAL and the LLM's history:
class ViewPolicy(Protocol):
name: str
async def project(
self,
wal: list[Envelope],
*,
participant_id: str,
channel: ChannelMetadata,
render_envelope: EnvelopeRenderer, # required — supplied by the adapter
name_for: NameResolver = default_name_resolver, # optional — resolves agent_id → Passport.name
) -> list[BaseEvent]: ...
It takes the WAL slice this participant should see and returns a list of BaseEvents that the framework feeds into the LLM turn as pre-populated stream history. render_envelope (an EnvelopeRenderer = Callable[[Envelope], str | None]) is supplied per-call by the channel's adapter so views stay adapter-neutral; name_for (a NameResolver = Callable[[str], str]) labels projection lines and defaults to default_name_resolver (identity). Adapters declare a default; you can override per-channel.
Built-in views
| View | Behaviour | Default for |
|---|---|---|
FullTranscript() |
Every visible substantive envelope the adapter's render_envelope returns a string for, in order. Speakers disambiguated by the assistant/user role bit (2-party). |
consulting |
WindowedSummary(recent_n=N) |
The last N visible substantive envelopes. If there are more, prepends a CompactionSummary placeholder with the count of elided turns. |
conversation (recent_n=10) |
NamedWindowedSummary(recent_n=N) |
Like WindowedSummary but prefixes each non-self line with [<sender name>]: (resolved via name_for) — needed in N-party channels where the role bit can't tell the "others" apart. |
discussion, workflow (recent_n = max(N*2, 4)) |
NamedTranscript() |
Like FullTranscript but with the same [<sender name>]: prefix on non-self lines. |
(not a default; opt-in for N-party full history) |
All honour audience — an envelope addressed only to [bob] doesn't appear in carol's projection.
from ag2.network import FullTranscript, WindowedSummary, NamedWindowedSummary
view = WindowedSummary(recent_n=12)
projected = await view.project(
wal_slice,
participant_id=carol.agent_id,
channel=metadata,
render_envelope=adapter.render_envelope, # required
name_for=hub.name_for, # optional; defaults to identity
)
Resolving the default
from ag2.network import resolve_view_policy
policy = resolve_view_policy(client, metadata)
Reads the adapter manifest's default_view_policy and instantiates the matching view from the registry. The default handler calls this once per turn — custom handlers should too unless they're deliberately bypassing the standard projection model.
Custom views
Implement the protocol, give it a unique name, and use it:
from ag2.events import BaseEvent, ModelMessage, ModelRequest, TextInput
from ag2.network import EV_TEXT, ViewPolicy
class FromOneOnly(ViewPolicy):
"""Show only envelopes from a single named sender."""
name = "from_one_only"
def __init__(self, sender_id: str) -> None:
self.sender_id = sender_id
async def project(
self,
wal,
*,
participant_id,
channel,
render_envelope, # required by the protocol
name_for=lambda agent_id: agent_id,
):
out: list[BaseEvent] = []
for env in wal:
if env.event_type != EV_TEXT or env.sender_id != self.sender_id:
continue
text = render_envelope(env) # adapter renders the envelope to a string (or None)
if text is None:
continue
out.append(
ModelMessage(text) if env.sender_id == participant_id
else ModelRequest([TextInput(text)])
)
return out
The BaseEvent types you emit shape how the LLM sees the history: ModelRequest([TextInput(...)]) for "from the user," ModelMessage(...) for "from the assistant," etc. Render the envelope body via the supplied render_envelope callback (so the view stays adapter-neutral) rather than reaching into event_data directly. See ag2.events for the full taxonomy.
Picking a view
- Short, focused exchanges →
FullTranscript(). Token budget isn't the bottleneck; coherence is. - Long-running 2-party exchanges →
WindowedSummary(recent_n=N)withNtuned to turn density. - Long-running N-party discussions / workflows →
NamedWindowedSummary(recent_n=N)so speaker labels survive the window (this is already thediscussion/workflowdefault). - Specialist agents that should ignore unrelated chatter → custom view that filters by audience or tags.
- Privacy-sensitive workflows → custom view that strips fields or redacts before projection.
Switching the view doesn't affect the WAL — every envelope is still there, every operator can still inspect it. Only the LLM's perception of history is shaped.
Skills — how an agent describes itself
Skills are markdown-with-frontmatter that the hub stores verbatim and surfaces to peers during peers(action="describe", name=...) lookup. Pass at registration:
agent_client = await hc.register(
agent,
Passport(name="researcher"),
Resume(claimed_capabilities=["research"]),
skill_md="""\
---
title: Research Assistant
expertise: [policy, finance]
---
# Researcher
A senior policy analyst. Best at:
- Scenario synthesis from multi-source briefs.
- Rebuttal review with confidence scores.
Limitations: not for code review or numerical analysis.
""",
)
Parsing the frontmatter
from ag2.network import parse_skill_frontmatter, ParsedSkill
parsed: ParsedSkill = parse_skill_frontmatter(skill_md)
print(parsed.frontmatter) # {"title": "Research Assistant", "expertise": [...]}
print(parsed.body) # the markdown body
Fallback skills
When no skill_md is provided, the hub generates one from the resume so peer lookup doesn't return empty handles:
from ag2.network import render_fallback_skill
skill_md = render_fallback_skill(passport, resume)
Useful when constructing skills programmatically — e.g. a tenant uploads a resume but no markdown.
Updating after registration
await hub.set_skill(agent_id, new_skill_md)
Emits AUDIT_KIND_SKILL_SET. Same audit shape as set_resume; tenant code can replace skills at any time.
The Envelope wire format
@dataclass(slots=True)
class Envelope:
channel_id: str
sender_id: str # agent_id
audience: list[str] | None # None = broadcast to all participants
event_type: str # "ag2.msg.text", "ag2.channel.invite", etc.
event_data: dict[str, Any] # event-specific payload
envelope_id: str = "" # hub-stamped on accept
task_id: str | None = None
causation_id: str | None = None # envelope_id this one is "in reply to"
trace_id: str | None = None
priority: Priority = "normal" # Priority is a Literal, not an enum (see below)
depth: int = 0 # delegation hop count; hub auto-increments on reply path
idempotency_key: str | None = None # reserved for cross-process transports
created_at: str = "" # hub-stamped ISO-Z on accept
ttl_seconds: int | None = None # per-envelope TTL; None defers to channel expires_at
The hub stamps envelope_id and created_at at admission time (and auto-increments depth on the reply path). Everything else comes from the sender. (There is no sequence field — WAL order is admission order; see "Reading the WAL".)
Substantive events
| Constant | String | event_data |
|---|---|---|
EV_TEXT |
"ag2.msg.text" |
{"text": "<body>"} |
EV_PACKET |
"ag2.packet" |
{"routing": {...}, "context_updates": {...}, "body": "<text>"} |
EV_TEXT carries plain text. EV_PACKET is the workflow adapter's atomic round-end capture — routing decision (matched against ToolCalled rules), accumulated context_vars mutations, and final body text bundled into one envelope. Posted by the framework after each Agent.ask round on a workflow channel; tool authors don't construct these directly.
Channel lifecycle events
| Constant | When |
|---|---|
EV_CHANNEL_INVITE |
Hub posts to each target when a channel is created. |
EV_CHANNEL_INVITE_ACK |
Each invitee posts when accepting. |
EV_CHANNEL_INVITE_REJECT |
Optional — invitee rejects (default handler doesn't, but you can override). |
EV_CHANNEL_OPENED |
Hub posts when all acks land. |
EV_CHANNEL_CLOSED |
Hub posts on any termination path; event_data.reason carries why. |
EV_CHANNEL_EXPIRED |
Hub posts when TTL sweeper closes the channel. |
EV_EXPECTATION_VIOLATED |
Hub posts when an evaluator's threshold is breached and the handler is notify. |
Context and task events
| Constant | String | When |
|---|---|---|
EV_CONTEXT_SET |
"ag2.context.set" |
Tool/participant emits to mutate channel-scoped context_vars. event_data is {"set": {...}, "delete": [...]}. |
EV_TASK_CANCEL_REQUEST |
"ag2.task.cancel_request" |
A peer asks the task owner to cancel. event_data is {"task_id": str, "reason": str}. The owner may honour it (Task.cancel) or ignore it. |
EV_TASK_CANCELLED |
"ag2.task.cancelled" |
Owner-emitted terminal envelope a peer handler can match on. event_data is {"task_id": str, "reason": str}. |
Task lifecycle (started / progress / completed / failed / expired) is not carried as wire envelopes — it's mirrored from the owner's stream straight into the hub's
TaskMetadatacache byTaskMirror. The twoEV_TASK_*constants above are the exception: cancellation has a peer-driven side that needs an addressable envelope. The default handler no-ops on anyag2.task.*event_type.
Audience and visibility
audience: list[str] | None controls who sees the envelope:
None— broadcast to all participants.[agent_id_1, agent_id_2, ...]— only those participants see it.
from ag2.network import visible_to
if visible_to(env, my_agent_id):
process(env)
Views (FullTranscript, WindowedSummary) honour audience — an envelope addressed only to [bob] doesn't appear in carol's projection.
Priority
Priority = Literal["background", "normal", "urgent"]
Priority is a string literal type, not an enum — set priority="urgent" (etc.) on the envelope directly. Higher-priority envelopes process ahead of lower in queue order. Use sparingly; most application envelopes leave priority at the default "normal".
Causation
causation_id marks an envelope as "in reply to" another:
await channel.send(reply_text, causation_id=incoming_envelope.envelope_id)
The default handler does this automatically when replying to an inbound EV_TEXT. Custom handlers should set it for logical replies — useful for threaded views.
Sending raw envelopes (custom event types)
channel.send(text, audience=...) wraps EV_TEXT for you. For custom event types build an Envelope and post it directly:
from ag2.network import Envelope
envelope = Envelope(
channel_id=channel.channel_id,
sender_id=alice.agent_id,
audience=[bob.agent_id],
event_type="myapp.review_request",
event_data={"document_id": "doc-123", "kind": "security"},
)
await alice.send_envelope(envelope)
The hub doesn't validate event_type against any allowlist — custom types pass through unmodified. Adapters fold only the event types they recognise (substantive ones, plus EV_PACKET under workflow, plus lifecycle ones); custom event types land on the WAL and are delivered to the audience but don't advance turn-taking state.
Custom event type guidelines
- Use a dotted namespace prefix (
"myapp.review_request", not"review") to avoid collision with futureag2.*events. - Keep
event_dataJSON-serialisable (no datetimes, dataclasses, etc.) so it round-trips through the store cleanly. - If multiple participants should react, set
audience=None. If only one, address it specifically; views filter out non-recipients. - Don't rely on adapters to do anything with custom types — they pass through. Your custom handler is responsible for processing them.
Reading the WAL
wal = await hub.read_wal(channel.channel_id)
for i, env in enumerate(wal):
print(f"{i:>3} {env.event_type} from={env.sender_id[:8]}")
Envelopes appear in admission order (WAL list order — there is no per-envelope sequence counter). The WAL is the canonical replay surface — Hub.hydrate() re-folds it through each adapter to rebuild in-memory state on restart.
Quick reference — imports
from ag2.network import (
# Default handler + hooks (for selective override)
default_handler,
read_wal_until,
resolve_view_policy,
stamp_dependencies,
# Plugin (auto-attached by attach_plugin=True) — peers/channels/tasks/context/delegate.
# `say` is NOT here; it comes from the channel adapter's tools_for(...).
NetworkPlugin,
# Non-LLM participant
HumanClient, # via HubClient.register_human(Passport(name=..., kind="human"))
PassportKind, # Literal["agent", "human", "remote_agent"] | None
# Views
ViewPolicy,
FullTranscript,
WindowedSummary,
NamedTranscript,
NamedWindowedSummary,
# Skills (peer discovery)
ParsedSkill,
parse_skill_frontmatter,
render_fallback_skill,
# Envelopes + events
Envelope,
Priority,
visible_to,
EV_TEXT,
EV_PACKET,
EV_CHANNEL_INVITE,
EV_CHANNEL_INVITE_ACK,
EV_CHANNEL_INVITE_REJECT,
EV_CHANNEL_OPENED,
EV_CHANNEL_CLOSED,
EV_CHANNEL_EXPIRED,
EV_EXPECTATION_VIOLATED,
EV_CONTEXT_SET,
EV_TASK_CANCEL_REQUEST,
EV_TASK_CANCELLED,
# Dependency injection keys (for tests outside the notify handler)
AGENT_CLIENT_DEP,
CHANNEL_DEP,
CHANNEL_STATE_DEP,
HUB_DEP,
TASK_DEP,
# Injects (for tool signatures)
AgentClientInject,
ChannelInject,
ChannelStateInject,
HubInject,
TaskInject,
)