AI agents are multiplying inside companies: one triages support tickets, another audits invoices, a third writes release notes. The problem is that they cannot talk to each other. Each one lives behind a custom REST API with its own payload shapes, its own auth quirks, and its own idea of what a "task" is. Wiring agent A to agent B means writing yet another brittle integration.
The Agent2Agent (A2A) protocol solves exactly this. Originally announced by Google in 2025 and now governed by the Linux Foundation, A2A is an open standard that lets opaque agents discover each other, exchange messages, delegate tasks, and stream progress updates — without exposing their internal state, prompts, or tools. Think of it as the missing third leg of the agent interoperability stack:
- MCP connects an agent to its tools and data (we covered this in our MCP server tutorial)
- ACP connects an editor or UI to an agent (see our ACP agent tutorial)
- A2A connects agents to other agents, across teams, vendors, and clouds
In this tutorial you will build a complete A2A setup in TypeScript with the official @a2a-js/sdk: an agent server that publishes an Agent Card, executes tasks, and streams status updates and artifacts, plus a client that discovers the agent and consumes its event stream. In the final step you will plug Claude into the executor so your agent produces real answers instead of canned strings.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- TypeScript basics (interfaces, async/await, generics)
- Familiarity with Express or any HTTP server framework
- An Anthropic API key for the final step (optional but recommended)
- A code editor (VS Code recommended)
What You'll Build
You will build a research summary agent exposed over A2A:
- An agent server on port 4000 that advertises itself via an Agent Card at a well-known URL
- An AgentExecutor that accepts a topic, works through a task lifecycle (submitted, working, completed), and emits a summary document as an artifact
- A client that discovers the agent from its card, sends messages, and consumes streaming updates in real time
- A Claude-powered variant of the executor that generates genuine summaries
By the end, any A2A-compatible orchestrator — LangGraph, CrewAI, Google ADK, or another agent you wrote yourself — can discover and delegate work to your agent with zero custom integration code.
Step 1: Project Setup
Create a fresh project and install the SDK. Express is a peer dependency required for the HTTP server integration:
mkdir a2a-research-agent && cd a2a-research-agent
npm init -y
npm install @a2a-js/sdk express uuid
npm install -D typescript tsx @types/express @types/node @types/uuid
npx tsc --initSet "type": "module" in your package.json and add two scripts:
{
"type": "module",
"scripts": {
"server": "tsx src/server.ts",
"client": "tsx src/client.ts"
}
}The @a2a-js/sdk package ships three entry points: the root export for shared types (Message, Task, AgentCard), @a2a-js/sdk/server for executor and request-handler classes, and @a2a-js/sdk/client for the client factory. Importing from the wrong entry point is the most common first-run error.
Step 2: Define the Agent Card
The Agent Card is your agent's public identity: a JSON document served at /.well-known/agent-card.json that tells other agents who you are, what skills you offer, which transports you speak, and what input and output modes you accept. Discovery in A2A starts here — a client fetches the card first and negotiates everything else from it.
Create src/card.ts:
// src/card.ts
import { AgentCard } from '@a2a-js/sdk';
export const researchAgentCard: AgentCard = {
name: 'Research Summary Agent',
description:
'Accepts a topic and returns a structured research summary as a markdown artifact.',
protocolVersion: '0.3.0',
version: '1.0.0',
// The public URL of the default transport endpoint
url: 'http://localhost:4000/a2a/jsonrpc',
skills: [
{
id: 'summarize-topic',
name: 'Summarize Topic',
description:
'Produces a concise, sourced summary for a given research topic.',
tags: ['research', 'summarization'],
examples: ['Summarize the current state of solid-state batteries'],
},
],
capabilities: {
streaming: true,
pushNotifications: false,
},
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
additionalInterfaces: [
{ url: 'http://localhost:4000/a2a/jsonrpc', transport: 'JSONRPC' },
{ url: 'http://localhost:4000/a2a/rest', transport: 'HTTP+JSON' },
],
};A few fields deserve attention:
- skills is how orchestrators route work. An orchestrator holding ten agent cards picks the agent whose skill tags and descriptions best match the subtask it wants to delegate. Write these like you would write good tool descriptions for an LLM — because that is often exactly what reads them.
- capabilities.streaming tells clients they may call the streaming endpoint and receive Server-Sent Events instead of a single blocking response.
- additionalInterfaces lists every transport you expose. The JS SDK supports JSON-RPC (the default), HTTP+JSON/REST, and gRPC.
Step 3: Implement the AgentExecutor
The AgentExecutor interface is the heart of the server SDK. You implement one method, execute, which receives a RequestContext (the incoming message, task id, and context id) and an ExecutionEventBus you publish events to. The SDK handles JSON-RPC parsing, task persistence, and SSE plumbing around you.
Start with the full task lifecycle. Create src/executor.ts:
// src/executor.ts
import { Task } from '@a2a-js/sdk';
import {
AgentExecutor,
RequestContext,
ExecutionEventBus,
} from '@a2a-js/sdk/server';
export class ResearchExecutor implements AgentExecutor {
async execute(
requestContext: RequestContext,
eventBus: ExecutionEventBus
): Promise<void> {
const { taskId, contextId, userMessage, task } = requestContext;
// Extract the topic from the first text part of the user message.
const firstPart = userMessage.parts[0];
const topic = firstPart.kind === 'text' ? firstPart.text : 'unknown topic';
// 1. Publish the initial task if this is a new conversation turn.
if (!task) {
const initialTask: Task = {
kind: 'task',
id: taskId,
contextId,
status: {
state: 'submitted',
timestamp: new Date().toISOString(),
},
history: [userMessage],
};
eventBus.publish(initialTask);
}
// 2. Signal that work has started.
eventBus.publish({
kind: 'status-update',
taskId,
contextId,
status: { state: 'working', timestamp: new Date().toISOString() },
final: false,
});
// 3. Do the actual work (placeholder for now — Claude arrives in Step 7).
const summary = await this.summarize(topic);
// 4. Emit the result as an artifact.
eventBus.publish({
kind: 'artifact-update',
taskId,
contextId,
artifact: {
artifactId: 'summary.md',
name: 'Research Summary',
parts: [{ kind: 'text', text: summary }],
},
});
// 5. Mark the task completed and close the event stream.
eventBus.publish({
kind: 'status-update',
taskId,
contextId,
status: { state: 'completed', timestamp: new Date().toISOString() },
final: true,
});
eventBus.finished();
}
private async summarize(topic: string): Promise<string> {
await new Promise((r) => setTimeout(r, 800)); // simulate work
return `# Summary: ${topic}\n\nThis is a placeholder summary.`;
}
cancelTask = async (): Promise<void> => {
// Called when a client cancels an in-flight task.
};
}The event sequence here — task, working status, artifact, final completed status, finished() — is the canonical A2A task lifecycle. Two rules matter:
- Always set
final: trueon the terminal status update. That is what tells the SDK (and the client's stream) that the task reached a terminal state. - Always call
eventBus.finished()when your executor is done publishing. Forgetting it leaves streaming clients hanging until they time out.
For quick request/response agents you can skip the task machinery entirely: publish a single Message event with role "agent" and call finished(). The client then receives a direct message instead of a task. Use tasks whenever work takes more than a moment or produces artifacts worth persisting.
Step 4: Wire Up the Express Server
Now assemble the pieces: the card, a task store, your executor, and the transport handlers. Create src/server.ts:
// src/server.ts
import express from 'express';
import { AGENT_CARD_PATH } from '@a2a-js/sdk';
import {
DefaultRequestHandler,
InMemoryTaskStore,
} from '@a2a-js/sdk/server';
import {
agentCardHandler,
jsonRpcHandler,
restHandler,
UserBuilder,
} from '@a2a-js/sdk/server/express';
import { researchAgentCard } from './card.js';
import { ResearchExecutor } from './executor.js';
const requestHandler = new DefaultRequestHandler(
researchAgentCard,
new InMemoryTaskStore(),
new ResearchExecutor()
);
const app = express();
// Serves the card at /.well-known/agent-card.json
app.use(
`/${AGENT_CARD_PATH}`,
agentCardHandler({ agentCardProvider: requestHandler })
);
// JSON-RPC transport (the A2A default)
app.use(
'/a2a/jsonrpc',
jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication })
);
// HTTP+JSON/REST transport
app.use(
'/a2a/rest',
restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication })
);
app.listen(4000, () => {
console.log('A2A research agent listening on http://localhost:4000');
});Run it and verify the card is discoverable:
npm run server
# in another terminal:
curl http://localhost:4000/.well-known/agent-card.jsonYou should see your full Agent Card as JSON. That single URL is all another agent needs to start working with yours.
InMemoryTaskStore keeps task state in process memory, which is perfect for development. In production you would implement the TaskStore interface over Redis or Postgres so tasks survive restarts and scale across replicas.
UserBuilder.noAuthentication is fine on localhost, but never in production. The A2A spec delegates auth to standard HTTP mechanisms — the securitySchemes field on the Agent Card advertises what you accept (bearer tokens, OAuth 2, API keys), and the SDK's UserBuilder hook is where you validate incoming credentials.
Step 5: Build the Client
The client side is refreshingly small. ClientFactory.createFromUrl fetches the Agent Card, picks a mutually supported transport, and returns a ready client. Create src/client.ts:
// src/client.ts
import { ClientFactory } from '@a2a-js/sdk/client';
import { MessageSendParams, Task } from '@a2a-js/sdk';
import { v4 as uuidv4 } from 'uuid';
const factory = new ClientFactory();
const client = await factory.createFromUrl('http://localhost:4000');
const params: MessageSendParams = {
message: {
kind: 'message',
messageId: uuidv4(),
role: 'user',
parts: [{ kind: 'text', text: 'The state of small modular reactors' }],
},
};
const result = await client.sendMessage(params);
if (result.kind === 'task') {
const task = result as Task;
console.log(`Task ${task.id} finished: ${task.status.state}`);
for (const artifact of task.artifacts ?? []) {
const part = artifact.parts[0];
if (part.kind === 'text') {
console.log(`--- ${artifact.name} ---\n${part.text}`);
}
}
} else {
console.log('Direct message reply:', result);
}Run npm run client with the server up and you will see the task complete and print the placeholder summary artifact. Notice what you did not write: no endpoint paths, no payload schemas, no polling logic. The card told the client everything.
Step 6: Stream Progress in Real Time
Blocking on sendMessage is fine for fast tasks, but research-style work can take a while. A2A streams task events over Server-Sent Events, and the client exposes them as an async generator. Add src/client-stream.ts:
// src/client-stream.ts
import { ClientFactory } from '@a2a-js/sdk/client';
import { MessageSendParams } from '@a2a-js/sdk';
import { v4 as uuidv4 } from 'uuid';
const factory = new ClientFactory();
const client = await factory.createFromUrl('http://localhost:4000');
const params: MessageSendParams = {
message: {
kind: 'message',
messageId: uuidv4(),
role: 'user',
parts: [{ kind: 'text', text: 'Progress in desalination tech' }],
},
};
for await (const event of client.sendMessageStream(params)) {
if (event.kind === 'task') {
console.log(`[task] created ${event.id} (${event.status.state})`);
} else if (event.kind === 'status-update') {
console.log(`[status] ${event.status.state}`);
} else if (event.kind === 'artifact-update') {
console.log(`[artifact] received ${event.artifact.artifactId}`);
}
}
console.log('Stream closed.');The events arrive in exactly the order your executor published them: task created, working, artifact, completed. In a real orchestrator UI you would map these to a progress indicator, and long-running agents can publish as many intermediate status updates and partial artifacts as they like.
Step 7: Give the Agent Real Intelligence with Claude
So far the executor returns a canned string. Let's make it earn its Agent Card. Install the Anthropic SDK:
npm install @anthropic-ai/sdk
export ANTHROPIC_API_KEY=sk-ant-...Then replace the summarize method in src/executor.ts:
// src/executor.ts (updated)
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
// ...inside ResearchExecutor:
private async summarize(topic: string): Promise<string> {
const response = await anthropic.messages.create({
model: 'claude-sonnet-5',
max_tokens: 1024,
system:
'You are a research assistant. Produce a concise markdown summary ' +
'with sections: Overview, Key Developments, Open Questions.',
messages: [{ role: 'user', content: `Topic: ${topic}` }],
});
const block = response.content[0];
return block.type === 'text' ? block.text : 'No summary generated.';
}For longer generations, you can go one step further and stream Claude's output through the A2A event bus as incremental artifact updates:
private async streamSummary(
topic: string,
publish: (chunk: string) => void
): Promise<void> {
const stream = anthropic.messages.stream({
model: 'claude-sonnet-5',
max_tokens: 1024,
messages: [{ role: 'user', content: `Summarize: ${topic}` }],
});
stream.on('text', (delta) => publish(delta));
await stream.finalMessage();
}Each chunk becomes an artifact-update event, and a streaming client renders the summary token by token — the same UX as a chat app, but flowing agent-to-agent through a standard protocol.
This composition is the important takeaway. The executor is just an async function: inside it you can call Claude, query a vector database, invoke MCP tools, or delegate to another A2A agent. The protocol does not care how the work happens, only how it is described, tracked, and delivered.
Testing Your Implementation
Verify the whole loop end to end:
- Card discovery:
curl http://localhost:4000/.well-known/agent-card.jsonreturns your card withprotocolVersionandskillspopulated. - Blocking flow:
npm run clientprints a completed task with asummary.mdartifact. - Streaming flow:
tsx src/client-stream.tsprints the four lifecycle events in order and then "Stream closed." - Task state: send a message, note the task id, then fetch it via a
tasks/getJSON-RPC call to confirm the store retained history and artifacts.
For protocol-level validation, the A2A project publishes a TCK (Technology Compatibility Kit) that exercises your server against the spec — worth running before you declare your agent production-ready.
Troubleshooting
The client throws on createFromUrl. The card fetch failed. Confirm the server is running and that you did not mount agentCardHandler on a custom path while the client expects the default well-known path.
Streaming clients hang after the last event. Your executor never called eventBus.finished(), or the terminal status update is missing final: true.
"Cannot find module" errors on server imports. Check the entry point: executor and handler classes come from @a2a-js/sdk/server, Express handlers from @a2a-js/sdk/server/express, and shared types from the package root.
Artifacts arrive empty. Every part needs an explicit kind field (text, file, or data). A part missing its kind is silently dropped by strict clients.
ESM import errors with tsx. Make sure "type": "module" is set and local imports use the .js extension (./card.js), which TypeScript resolves to the .ts source under modern module resolution.
Next Steps
- Implement a persistent
TaskStoreover Redis and addcancelTasklogic that actually aborts in-flight Claude calls - Advertise
securitySchemeson your card and validate bearer tokens in a customUserBuilder - Build a second agent and have the first delegate subtasks to it — multi-agent pipelines are just A2A clients inside executors
- Combine protocols: expose your agent's data sources via an MCP server and keep A2A for agent-to-agent delegation
- Explore the Claude Agent SDK if you want a full agent loop behind your executor
Conclusion
You built a complete A2A agent in TypeScript: an Agent Card for discovery, an executor that walks the full task lifecycle with streaming status updates and artifacts, a client that consumes both blocking and streaming flows, and a Claude-powered brain behind it all. The pattern to remember is the separation of concerns — A2A standardizes how agents talk, and leaves how agents think entirely to you. As orchestration frameworks and enterprise platforms converge on the protocol, an A2A-compliant agent is one that any of them can discover, trust, and put to work.