Agent graph
The agent-graph API lets you compose specialist agents — returns-bot, billing-bot, order-bot, and so on — under a router that picks the right specialist for each turn. Specialists are reached over the Google Agent-to-Agent (A2A) protocol; routing is in-process and observable.
This guide covers the mental model, the public API, the wire format, the
inspector, and the pitfalls. For a working demo, see
apps/applied-ai-retail/src/app/support/page.tsx and
apps/showcase/src/app/agent-graph/page.tsx.
Mental model
A graph has three kinds of node:
- Routers decide. They evaluate declarative rules against an incoming user turn (optionally augmented with an intent label) and dispatch to a single downstream node.
- Specialists answer. Each specialist is a remote A2A endpoint. The SDK speaks A2A as a client — it does not host the specialist.
'human'is a reserved escalation target. Routing to'human'delegates to the existingHandoffControllerand bot-to-human flow, unchanged.
user turn ──▶ router ──▶ specialist (A2A) ──▶ TransportEvents ──▶ ChatSession
│
└─▶ 'human' ──▶ HandoffController.request(...)
Internal handoff (router → specialist) is fast and observable. External
handoff (router → human) is unchanged — it goes through the existing
HandoffController, HandoffStatus, and AgentTransferPart flow.
Quickstart
import {
agentGraph,
defineRouter,
defineSpecialist,
createA2AAgentClient,
createHeuristicIntentClassifier,
createAgentGraphTransport,
createChatClient,
createHttpTransport,
} from '@google/gecx-chat';
const returns = defineSpecialist({
id: 'returns',
description: 'Returns, refund status, return labels',
client: createA2AAgentClient({
agentCardUrl: 'https://returns.example.com/.well-known/agent.json',
}),
});
const billing = defineSpecialist({
id: 'billing',
client: createA2AAgentClient({
agentCardUrl: 'https://billing.example.com/.well-known/agent.json',
}),
});
const order = defineSpecialist({
id: 'order',
client: createA2AAgentClient({
agentCardUrl: 'https://order.example.com/.well-known/agent.json',
}),
});
const triage = defineRouter({
id: 'triage',
intent: createHeuristicIntentClassifier({
labels: ['returns', 'billing', 'order'],
rules: {
returns: [/refund/i, /\breturn(s|ed|ing)?\b/i],
billing: [/charge[ds]?/i, /bill(ing|ed)?/i, /invoice/i],
order: [/\border(ed|s)?\b/i, /shipping/i, /\btrack(ing)?\b/i],
},
}),
rules: [
{ when: ({ intent }) => intent?.label === 'returns', routeTo: 'returns' },
{ when: ({ intent }) => intent?.label === 'billing', routeTo: 'billing' },
{ when: ({ intent }) => intent?.label === 'order', routeTo: 'order' },
{
when: ({ message }) => /\bhuman\b|\bagent\b/i.test(message.text ?? ''),
routeTo: 'human',
reason: 'explicit human request',
},
],
fallback: { routeTo: 'order', reason: 'default to order' },
});
const supportGraph = agentGraph({
entry: 'triage',
nodes: [triage, returns, billing, order],
});
// Wire the graph in as a transport. The `fallbackTransport` is invoked only
// for the rare case where the graph cannot route a turn at all (e.g. no
// rules and no fallback) — typical setups never hit it.
const client = createChatClient({
transport: createAgentGraphTransport({
graph: supportGraph,
fallbackTransport: createHttpTransport({ /* your default */ }),
onHumanEscalation: (decision, request) => {
// Wire to your existing HandoffController, e.g.:
// handoffController.request({ reason: decision.reason, ... });
},
}),
});
That's the entire public surface. Rules, intent classifier, and specialist clients are all replaceable.
Anatomy of a routing decision
When a user turn enters a router node:
- Compose context —
RouterContext { message, intent: undefined, ... }. - Classify intent (if a classifier is configured). Heuristic classifier runs synchronously in ~1ms; an LLM classifier should resolve in <300ms.
- Evaluate rules in order. First match wins. Each rule's
whenpredicate may beboolean | Promise<boolean>. Predicate exceptions count as non-matches. - If no rule matches, apply
fallback— either{ routeTo: NodeId }or a function returning one. With no fallback configured, the router routes to'human'as a safe default. - Emit a
RouteDecisioncarryingfrom,to,latencyMs,reason,ruleIndex, and the inferred intent. The decision is:- pushed to the runtime's recent-decisions ring buffer (visible in the inspector),
- emitted as an
agent.routedProductAnalyticsEvent, - embedded in a
bot_to_botHandoffStatusEventon the wire so renderers and replays see it.
- Dispatch. A specialist target invokes
A2AAgentClient.stream(...). A'human'target calls the runtime'sonHumanEscalationcallback and short-circuits the stream — your existingHandoffControllertakes over.
Steps 1–5 stay under 200ms with the heuristic classifier. The A2A round-trip itself adds the rest of the latency budget; total end-to-end target is <500ms.
Wire format
Internal handoff is first-class on the wire. The runtime emits a single
HandoffStatusEvent per routing decision before any specialist text:
{
"type": "handoff.status",
"status": "completed",
"transferType": "bot_to_bot",
"targetAgent": "returns",
"routeDecision": {
"from": "triage", "to": "returns",
"latencyMs": 4.2,
"ruleIndex": 0,
"intentLabel": "returns",
"intentConfidence": 0.83,
"reason": "returns intent"
},
"graphPath": ["triage", "returns"]
}
Renderers that already understand handoff.status keep working. Renderers
that want to surface routing decisions can switch on transferType === 'bot_to_bot' and read routeDecision.
bot_to_human events from the existing handoff path are unchanged.
Telemetry
Six new ProductAnalyticsEvent kinds:
| Event | When |
|---|---|
agent_graph_entered | Turn arrives at the entry node. |
agent_routed | Router emits a decision. Carries from, to, latencyMs, intentLabel. |
agent_specialist_started | A2A call begins. |
agent_specialist_completed | A2A call resolved successfully, with durationMs. |
agent_specialist_failed | A2A call threw, with errorCode. |
agent_graph_exited | Turn leaves the graph, with totalLatencyMs + finalNode. |
These flow through the existing ProductAnalyticsCollector sinks (HTTP,
console, GA4, Segment) — no new wiring required.
The DebugBundle gains an optional agentGraph section (snapshot + recent
decisions) that the inspector renders as a live graph tab.
Inspector
The inspector reads DebugBundle.agentGraph and renders:
- Static topology — every node with its kind badge (router / specialist / human) and declared edges.
- Active node highlighting — the most recent
RouteDecision.tois highlighted; the matched edge animates briefly. - Decision panel — the last decision's rule index, intent label, latency, and reason.
Apps that want to embed a graph view themselves can:
const transport = createAgentGraphTransport({ graph, fallbackTransport });
transport.onAgentGraphEvent((event) => {
if (event.kind === 'graph.routed') {
// Update your UI with event.decision
}
});
const recent = transport.recentDecisions();
Pitfalls
1. Routing loops. If two routers can hand off to each other, set a
deliberate base case in their rules. The runtime caps recursion at
maxDepth (default 3) and throws AGENT_GRAPH_MAX_DEPTH to prevent runaway
turns.
2. Heuristic classifier doesn't stem. 'charge' does not match
'charged'. Use RegExp patterns (/charge[ds]?/i) for stem-aware matching,
or wire an LLM classifier with createLLMIntentClassifier.
3. Fallback to 'order' is opinionated. When no rule matches, the
fallback routes the turn somewhere — make sure that "somewhere" is a
specialist that can gracefully say "I'm not sure, here are some options"
rather than confidently mishandling the turn. Prefer routeTo: 'human' when
in doubt.
4. AgentCard discovery is one-shot per process. createA2AAgentClient
fetches the AgentCard on first use and caches it for an hour by default.
Specialist endpoint changes that move url won't propagate until cache
expiry; restart, lower the TTL, or clear the cache via
createAgentCardCache() if you need faster turnover.
5. Memory is shared across the graph. Every node sees the same
MemoryStore. Specialists making decisions on memory state should treat
entries as advisory — concurrent turns may race. Use key-scoped
upserts when you need replace-on-write semantics.
6. The fallbackTransport is not a default specialist. It is only
invoked when the graph cannot route a turn at all (e.g. an empty router
with no fallback). Prefer an explicit specialist or routeTo: 'human' for
the unmatched-turn case.
Testing
Use createMockA2AClient for unit tests and demos:
const returnsBot = createMockA2AClient({
name: 'returns-bot',
replies: [
{ match: (req) => /refund/i.test(req.request.text ?? ''), text: 'Refund initiated.' },
{ fail: { code: 'A2A_AGENT_UNAVAILABLE' } }, // exercise the fallback
],
});
The mock client honors the same A2AAgentClient shape as the real one, so
graph wiring is identical between tests, demos, and production.
docs/guides/agent-graph.md