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:

  1. Routers decide. They evaluate declarative rules against an incoming user turn (optionally augmented with an intent label) and dispatch to a single downstream node.
  2. Specialists answer. Each specialist is a remote A2A endpoint. The SDK speaks A2A as a client — it does not host the specialist.
  3. 'human' is a reserved escalation target. Routing to 'human' delegates to the existing HandoffController and 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:

  1. Compose contextRouterContext { message, intent: undefined, ... }.
  2. Classify intent (if a classifier is configured). Heuristic classifier runs synchronously in ~1ms; an LLM classifier should resolve in <300ms.
  3. Evaluate rules in order. First match wins. Each rule's when predicate may be boolean | Promise<boolean>. Predicate exceptions count as non-matches.
  4. 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.
  5. Emit a RouteDecision carrying from, 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.routed ProductAnalyticsEvent,
    • embedded in a bot_to_bot HandoffStatusEvent on the wire so renderers and replays see it.
  6. Dispatch. A specialist target invokes A2AAgentClient.stream(...). A 'human' target calls the runtime's onHumanEscalation callback and short-circuits the stream — your existing HandoffController takes 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:

EventWhen
agent_graph_enteredTurn arrives at the entry node.
agent_routedRouter emits a decision. Carries from, to, latencyMs, intentLabel.
agent_specialist_startedA2A call begins.
agent_specialist_completedA2A call resolved successfully, with durationMs.
agent_specialist_failedA2A call threw, with errorCode.
agent_graph_exitedTurn 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.to is 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.

Source: docs/guides/agent-graph.md