DEV Community

Cover image for Building a Real A2A Handoff: Researcher Writer Over the Agent-to-Agent Protocol
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

Building a Real A2A Handoff: Researcher Writer Over the Agent-to-Agent Protocol

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 (plus failed, 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  │          │                    │
   └────────────────────┘          └────────────────────┘
Enter fullscreen mode Exit fullscreen mode
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"]
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Generates 2–3 search queries with the LLM (falling back to deterministic templates if that call fails).
  2. Runs real Tavily searches against each query and de-dupes results by URL.
  3. Synthesizes the source excerpts into a Markdown summary with the LLM, citing sources inline as [1], [2], etc.
  4. 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 })),
};
Enter fullscreen mode Exit fullscreen mode

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":[…]}}}
Enter fullscreen mode Exit fullscreen mode

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 } } });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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; });
Enter fullscreen mode Exit fullscreen mode

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; });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

What I learned

  1. 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.
  2. 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.
  3. 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.
  4. Sanitize everything the model says. Model output is untrusted input.

Try it

npm install
npm run dev
# open http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

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)