TL;DR — I built a protocol-education demo where two independent agents (a Researcher and a Writer) discover each other via A2A agent cards and hand off a research task over the protocol — real Tavily web search, real SSE streaming, and a React timeline showing every JSON-RPC round-trip. No orchestration framework. The transport is the point. Here's how it works, the code, and the gotchas I hit.
The problem: agents can't talk to each other
Most "agent orchestration" demos wire agents together inside a single framework — LangChain, LangGraph, Agno — where the coordination is hidden inside the library. That's great for building one app, but it doesn't solve the interop problem: how do independent agents built by different teams, in different languages, on different runtimes, talk to each other?
That's what the Agent-to-Agent (A2A) protocol (agent2agent.dev) is for. It's an open, vendor-neutral protocol — think "HTTP for agents." It defines:
- Agent discovery via a machine-readable agent card.
- A task lifecycle:
submitted → working → completed(plusfailed,canceled,rejected,input-required). -
message/send— JSON-RPC 2.0 request/response. -
message/stream— Server-Sent Events (SSE) for incremental output.
So I decided to prove it works by building the smallest faithful implementation I could: a Researcher agent that does real web search, and a Writer agent that streams a structured draft — with the browser acting as the A2A client driving the whole handoff.
The architecture
Three independent processes, one command (npm run dev):
┌────────────────────────────────────────────────────────────┐
│ Browser — Vite + React (5173) │
│ the A2A client │
│ topic input · config panel · timeline · streamed draft │
└───────────────┬──────────────────────────────┬─────────────┘
│ JSON-RPC over HTTP (CORS) │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Researcher (3001) │ │ Writer (3002) │
│ streaming: false │ │ streaming: true │
│ message/send │ │ message/stream │
│ └ Tavily ×2-3 │ │ └ LLM SSE stream │
│ └ LLM synthesis │ │ │
└────────────────────┘ └────────────────────┘
sequenceDiagram
participant U as UI (Client, 5173)
participant R as Researcher (3001)
participant W as Writer (3002)
U->>R: agent/getCard
R-->>U: card (streaming:false)
U->>W: agent/getCard
W-->>U: card (streaming:true)
U->>R: message/send { topic }
R-->>U: findings + real source URLs
U->>W: message/stream { findings }
W-->>U: SSE working → message/part × N → completed
Stack: Vite + React + TypeScript · Node.js + Express + TypeScript · JSON-RPC 2.0 · SSE · Tavily · OpenAI-compatible chat completions · marked + DOMPurify for markdown rendering · concurrently to run everything.
Part 1 — Agent discovery with agent cards
Every A2A agent advertises a card at GET /.well-known/agent.json and via the JSON-RPC method agent/getCard. The card tells a client who the agent is, what it can do, and how to talk to it.
The Writer advertises streaming support — this is the contract that lets a client know it can call message/stream:
{
"name": "Writer",
"description": "Turns research findings into a structured draft, streaming token-by-token.",
"url": "http://localhost:3002",
"version": "0.1.0",
"skills": [{ "id": "structured-drafting", "name": "Structured Drafting", "description": "Writes structured drafts from research findings" }],
"capabilities": { "streaming": true },
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"]
}
The client fetches both cards at the start of a run and renders them in the timeline, with a green/red connection dot based on reachability.
Part 2 — The Researcher: real search + synthesis
The Researcher implements message/send. Given a topic, it:
- Generates 2–3 search queries with the LLM (falling back to deterministic templates if that call fails).
- Runs real Tavily searches against each query and de-dupes results by URL.
-
Synthesizes the source excerpts into a Markdown summary with the LLM, citing sources inline as
[1],[2], etc. - Returns the findings plus the real source URLs as structured data.
The key honesty rule: never fabricate sources.
// researcher-agent/src/index.ts (simplified)
const queries = await generateQueries(config, topic);
const sources = await runSearches(tavilyKey, queries); // real Tavily calls
const findings = await synthesize(config, topic, queries, sources);
return {
taskId: randomUUID(),
status: { state: 'completed' },
message: { role: 'agent', parts: [{ kind: 'text', text: buildFindingsText(...) }] },
live: true,
queries,
sources: sources.map((s) => ({ title: s.title, url: s.url, content: s.content })),
};
The honest fallback. If no Tavily key is configured, the Researcher doesn't fake a search — it clearly labels its output "⚠️ no live search — model knowledge only" and returns zero source URLs. This matters: a demo that fabricates sources teaches the wrong lesson about AI tooling.
Part 3 — The Writer: SSE streaming
The Writer implements message/stream. This is where the protocol gets interesting.
The client can't use EventSource here because it needs to POST the findings. So it opens a streaming fetch and parses the SSE manually with a ReadableStream reader.
The server emits a sequence of JSON-RPC objects as SSE data: lines:
data: {"jsonrpc":"2.0","method":"message/part","params":{"taskId":"…","status":{"state":"working"}}}
data: {"jsonrpc":"2.0","method":"message/part","params":{"taskId":"…","part":{"kind":"text","text":"# Title"}}}
data: {"jsonrpc":"2.0","method":"message/part","params":{"taskId":"…","part":{"kind":"text","text":"\n\n## Summary"}}}
…
data: {"jsonrpc":"2.0","method":"message/complete","params":{"taskId":"…","status":{"state":"completed"},"message":{"role":"agent","parts":[…]}}}
On the server, the model's own token stream is piped through and re-emitted as message/part events:
const streamRes = (await chatCompletion(config, [{ role: 'user', content: prompt }], { stream: true })) as Response;
const reader = streamRes.body!.getReader();
const decoder = new TextDecoder();
let buffer = '', full = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// split on '\n', handle lines that start with "data:"
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
full += delta;
sendEvent(res, { jsonrpc: '2.0', method: 'message/part', params: { taskId, part: { kind: 'text', text: delta } } });
}
}
On the client, each message/part appends to the draft state, producing the live typewriter effect:
// client/src/lib/a2a.ts — the SSE parser
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line.startsWith('data:')) continue;
onEvent(JSON.parse(line.slice(5).trim())); // -> append chunk / update status
}
}
The gotcha that will bite you: req.on('close')
This is the bug that cost me a debugging session, and it's exactly the kind of subtlety a protocol demo should surface.
I originally detected client disconnects with:
req.on('close', () => { aborted = true; });
It fired immediately — as soon as Express finished consuming the request body — so the Writer sent the working event and then silently aborted before any message/part arrived. The client saw working and then nothing.
The fix is to listen on the response instead:
res.on('close', () => { aborted = true; });
res.on('close') fires when the client actually disconnects (or after the response ends), which is what we actually want to detect.
Security note: render the markdown safely
The Writer returns LLM-generated Markdown. To show both the raw source and a rendered preview, I used marked for parsing and DOMPurify to sanitize before dangerouslySetInnerHTML — so the model can't inject <script> tags. Treating model output as untrusted input is a habit worth building.
// client/src/lib/markdown.ts
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({ gfm: true, breaks: true });
export function renderMarkdown(md: string): string {
const raw = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw);
}
What I learned
- Interop is a contract, not a library. Agent cards + JSON-RPC + SSE are all a client needs to coordinate agents. The protocol forces you to think about messages and state, not framework internals.
- Streaming is the hard part. SSE over POST, incremental parts, disconnect handling, and re-emitting a model's token stream are where the real complexity lives.
- Honesty beats fake data. A fallback that says "model knowledge only, no sources" is more credible — and more educational — than a demo that pretends to search.
- Sanitize everything the model says. Model output is untrusted input.
Try it
npm install
npm run dev
# open http://localhost:5173
Enter a topic (e.g. "local-first AI agents 2026"), add a Tavily key in the config panel for live search, and watch the two agents discover each other and hand off the task over A2A.
Code & more: https://www.dailybuild.xyz/project/245-handoff
Top comments (0)