Tutorial: your first A2A client
This walks you through building the smallest possible a2a-query client: connect to one agent, send one message, read the result — then prove to yourself that the second read didn’t hit the network. No real server needed; you’ll talk to an in-process mock agent that speaks the real A2A wire protocol underneath, so everything you see here behaves exactly like it would against a live agent.
Takes about 5 minutes.
1. Install
Section titled “1. Install”npm install @johnhenry/a2a-query @johnhenry/agent-query-core @a2a-js/sdka2a-query is the A2A-specific client; agent-query-core is the reactive-cache engine it’s built on (you don’t need to touch it directly for this tutorial, but it’s worth installing explicitly so you know it’s there — every client in the family shares it). @a2a-js/sdk is the official A2A SDK, a required peer dependency.
2. Create the file
Section titled “2. Create the file”Create hello-agent.ts:
import { A2AQuery, type TaskHandle } from "@johnhenry/a2a-query";import { MockA2AAgent, echoExecutor } from "@johnhenry/a2a-query/testing";import type { Message } from "@a2a-js/sdk";
// A real in-process A2A agent — same server stack (DefaultRequestHandler +// JsonRpcTransportHandler + InMemoryTaskStore) a live server uses, just// wired to an injected fetch instead of a socket. It echoes back whatever// text you send it, prefixed with "echo: ".const mock = new MockA2AAgent(echoExecutor(), { name: "echo-agent" });
const q = new A2AQuery({ agents: { echo: { url: mock.url, fetchImpl: mock.fetchImpl } }, taskPollMs: 25,});
const message: Message = { messageId: "m-1", role: "user", parts: [{ content: { $case: "text", value: "hello, agent" } }],} as never;
// Fetch the agent's card once...const first = await q.card("echo");console.log("agent:", first.name);
// ...send a real message and wait for the task to complete...const reply = await q.sendMessage("echo", message);if (typeof reply === "object" && "result" in reply) { const handle = reply as TaskHandle; const task = await handle.result(); // resolves when the task COMPLETES const text = (task.artifacts ?? []) .flatMap((a) => a.parts.map((p) => (p.content?.$case === "text" ? String(p.content.value) : ""))) .join(""); console.log("artifact:", text);}
// ...then fetch the card again. This second call does NOT go back over// the wire — it's a cache hit, and you can prove it: the object you get// back is the exact same object as `first`, not a fresh copy.const second = await q.card("echo");console.log("second card fetch is cached (same object):", second === first);3. Run it
Section titled “3. Run it”npx tsx hello-agent.tsYou should see:
agent: echo-agentartifact: echo: hello, agentsecond card fetch is cached (same object): trueWhat just happened
Section titled “What just happened”q.card("echo")resolved the agent’s card over the (mock) wire and cached it.a2a-querykeys agent cards by agent name and treats them as fresh for a default stale time (5 minutes) — so the secondq.card("echo")call returned the cached entry synchronously instead of refetching.second === firstbeingtrueis that cache hit made visible: same object reference, not a re-fetched copy that happens to look the same.q.sendMessage("echo", message)sent a real A2A message and got back aTaskHandle— a poll-driven handle whose snapshots land in the same reactive cache.handle.result()awaited the task all the way to completion before reading its artifacts.- None of this touched a network socket.
MockA2AAgentruns the SDK’s real server stack in-process, so swappingmock.url/mock.fetchImplfor a real agent’s URL is the only change needed to point this at production.
Where to go next
Section titled “Where to go next”- Examples across the family — the full graded set, including live status subscriptions, human-in-the-loop approvals, and multi-agent dashboards.
- a2a-query — the full reference for this package.
- Agent Query overview — how
mcp-queryandacp-queryshare the sameagent-query-coreengine, if your next agent speaks a different protocol.