Skip to content

Multi-agent with A2A: a worked example

A runnable three-agent system — a researcher, a critic, a writer — orchestrated over A2A, all thinking through one relay.

The whole example lives in docs/examples/multiagent/, with a justfile that starts everything. Its README is the runnable quickstart; this page is the argument for why it is built the way it is.

Why A2A, and not three function calls

The honest question about any multi-agent demo is: why is this a protocol and not three function calls? Most of the time it should be function calls — if every agent is yours, in one language, in one process, a protocol is pure overhead.

So this example is deliberately built where a function call cannot reach. Two of its agents are shell scripts served by the Go a2a CLI; the third is a Python program on the Python a2a-sdk. And there are two orchestrators — one that is the Go CLI, one that is the Python SDK — either of which drives all three agents without knowing which language any of them is written in.

A Go binary cannot call a Python function. A Python process cannot call into a Go binary's shell script. Something has to carry the task across that boundary, and that something is A2A. Swap orchestrate.sh for orchestrate.py and nothing changes — that is the whole point, and it is what makes this A2A rather than three function calls in a trench coat.

flowchart LR
    orchgo(["orchestrate.sh<br/>(Go a2a CLI)"])
    orchpy(["orchestrate.py<br/>(Python SDK)"])
    subgraph agents["agents — one A2A server each, its own card"]
        direction TB
        res["researcher<br/>:9101 · shell / Go CLI"]
        crit["critic<br/>:9102 · PYTHON"]
        wri["writer<br/>:9103 · shell / Go CLI"]
    end
    relay["agent-relay"]
    sub["Anthropic subscription"]
    ollama["local Ollama<br/>(free, offline)"]

    orchgo -- "A2A" --> agents
    orchpy -- "A2A" --> agents
    res --> relay
    crit --> relay
    wri --> relay
    relay -- "sonnet, haiku…" --> sub
    relay -- "granite4.1:3b" --> ollama

One relay, several agents

The instinct to run several relays — one per agent — is the wrong one, and it is worth saying why.

A multi-agent system is interesting because its agents differ by role: a researcher, a critic, a writer. They do not become interesting by differing in which model they run on. Several relays would give you several agents with the same role and different brains — the least useful axis to vary.

So the specialisation belongs in the agents, and the relay stays what it is: the thing that thinks, underneath all of them. An agent's whole identity here is a system prompt plus a model.

Several relays do have legitimate uses — but they are about deployment, not composition: a relay with agentic execution enabled and one without are two different privilege boundaries, and that separation is a process-level thing.

What the relay buys you: a brain per agent, priced per job

Each agent picks its model from the same relay, with the same token. The critic (judgement) and the writer (prose) are worth the subscription. The researcher is not — gathering is not where the money should go, so RESEARCHER_MODEL can point at a local Ollama model (granite4.1:3b, via just run-hybrid): free, offline, no data leaves the machine.

agent model cost
researcher granite4.1:3b (local) $0
critic sonnet earns the subscription
writer sonnet earns the subscription

One word of config apart. That is a real orchestration decision — spend where it matters — and it is the relay that makes it a one-word change.

The agents are real A2A servers

Each agent publishes its own Agent Card and serves the JSON-RPC binding. The orchestrator never imports them: it is handed three base URLs, reads the cards to learn what each agent is, and delegates over the protocol — exactly as it would to an agent someone else wrote, on another machine, in another language. Here, two of them genuinely are in another language.

Two details bite anyone wiring heterogeneous agents together, both handled in the example and written up in upstream-bugs.md: the Go CLI's auto-generated Agent Card omits fields the proto marks REQUIRED (so the example serves hand-written --card files), and the two SDKs default to different transports (so the example pins everything to JSONRPC). An A2A reply is also a oneof — a Task with artifacts or a Message with parts; the Go-served agents return the first, the Python agent the second. Both orchestrators read both shapes, because a client that reads only one breaks against half the ecosystem.

Running it

Start a relay in the repo root (just run, or just run-hybrid if the researcher points at a local model), then from docs/examples/multiagent/:

export RELAY_URL=http://127.0.0.1:18082
export RELAY_TOKEN=$(just print-token)   # from the repo root

just setup     # install the Go a2a CLI and the Python SDKs into .venv
just agents    # start all three agents
just both "Why keep a personal inference relay on loopback?"

just both runs both orchestrators over the same three agents — the Go CLI first, then the Python SDK. Neither knows which agent is written in which language; both produce the same three-stage result. just run-go and just run-py run them one at a time; just clean stops the agents.

What it does

[1] researcher — shell agent, served by the Go CLI
      **Privacy engineer**: Keep sensitive data on-machine without transmission …
      **Cost manager**: Eliminate inference API charges during development …

[2] critic — PYTHON agent, on the Python SDK
      **Cost manager** — "eliminate charges" is true only for locally-hosted
      models; if the relay proxies to a remote paid API, costs aren't eliminated,
      just intercepted. *Falsified if* the relay's backend is a metered endpoint.

[3] writer — shell agent again
      **Cost manager**: Avoids inference API charges during dev/test — genuinely
      true for a locally-hosted model, but not if the relay just proxies to a
      metered cloud backend.

The critic earns its keep: it catches the researcher's silent assumption that a loopback relay is always paired with a local model, and the writer folds that correction into the final answer. Three roles, two languages, one relay.

Where to go next

  • Give an agent tools. These three only talk. An agent that acts sends tools[] and runs the client-tool loop — the relay serves it on both wires.
  • Let an agent run code on the host. Present the agentic credential and the relay executes in a workspace, returning the files as artifacts. That is what the relay's own A2A adapter exposes — useful when your relay is the peer in someone else's network, which is a different job from the one on this page.
  • Route by difficulty, not by hand. Here the model choice is fixed per agent. A triage agent (local, free) could decide which brain each task deserves.

This is an example, not a framework

The relay is not an agent framework, and this page does not make it one. If you want persistence, retries, planning and human-in-the-loop, reach for a real orchestration framework (Google ADK has first-class A2A support, LangGraph and CrewAI are options) and point its agents at the relay exactly as these three do.