ai-ml

A2A Protocol Tutorial: Multi-Agent Orchestration (2026)

١٦ يوليو ٢٠٢٦

A2A Protocol Tutorial: Multi-Agent Orchestration (2026)

The Agent2Agent (A2A) protocol is a Linux Foundation open standard for AI agents to discover each other and exchange tasks over HTTP and JSON-RPC. This guide builds two real TypeScript agents with the official @a2a-js/sdk — a sub-agent and an orchestrator that delegates to it — and runs them end to end.

TL;DR

You'll build two independent A2A servers with the official @a2a-js/sdk@0.3.14: a Currency Agent that converts an amount into USD, and a Trip Budget Agent that parses an expense, calls the Currency Agent over A2A as a client, and returns a budget line. That's the orchestrator/sub-agent pattern in practice — one agent's own executor acting as an A2A client to a peer agent — built on top of A2A's task-delegation model rather than anything the protocol mandates directly1. You'll verify both agents with curl against their Agent Cards and raw JSON-RPC, run a client script that calls the orchestrator with both a blocking and a streaming request, and see the real terminal output. Runtime: Node.js 22+, @a2a-js/sdk@0.3.14. Build time: 30-40 minutes.

What You'll Learn

  • What an Agent Card is and how a client discovers one at /.well-known/agent-card.json
  • How to build an A2A server with AgentExecutor, DefaultRequestHandler, and the Express integration
  • The A2A Task lifecycle (submittedworkingcompleted/failed) and how artifacts attach to a task
  • How to build an orchestrator whose own executor calls a second agent over A2A — the practical shape of the orchestrator/sub-agent pattern
  • How to consume both the blocking sendMessage API and the streaming sendMessageStream API (Server-Sent Events) from the official client
  • Where A2A ends and MCP begins, so you don't reach for the wrong protocol

Prerequisites

  • Node.js 22 or later. This tutorial is verified against Node 22.22.3; Node 24 is the current Active LTS release (Active Support until October 2026, Security Support until April 2028)2. The @a2a-js/sdk package itself only requires Node 18+, but Node 18 and 20 are both past end-of-life as of mid-20262, so don't build new agents on them.
  • Basic familiarity with TypeScript and Express.
  • No API key required — both agents in this tutorial run entirely offline with deterministic logic, so you can follow along without an Anthropic, OpenAI, or Google API key. In a real deployment you'd replace the executor's business logic with a call to an LLM (for example, a Messages API tool-calling loop3), but the A2A wiring itself is identical either way.

Step 1: Project Setup

Create a project and install the official SDK plus Express, uuid, and a TypeScript toolchain:

mkdir a2a-tutorial && cd a2a-tutorial
npm init -y
npm install @a2a-js/sdk@0.3.14 express@5.2.1 uuid@14.0.1
npm install -D typescript@7.0.2 tsx@4.23.1 @types/node@26.1.1 @types/express@5.0.6

@a2a-js/sdk@0.3.14 is the latest version on npm's latest dist-tag as of this writing, and it implements A2A Protocol Specification v0.345. There's also a 1.0.0-beta.0 release on the next tag with support for the newer v1.0 spec — more on that trade-off below.

Add "type": "module" to package.json so you can use top-level await, then create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

Create a src/ directory — you'll add three files to it over the next few steps.

Step 2: Build the Currency Sub-Agent (A2A Server)

The Currency Agent is the sub-agent in this system: a small, single-purpose A2A server that converts an amount into USD. It has no knowledge of the orchestrator that will call it — that's the point of A2A's "opaque" design, where agents interact without sharing internal logic1.

Every A2A server needs three things: an Agent Card (a JSON document describing what the agent does), an AgentExecutor (your agent's actual logic), and a request handler wired to a transport (Express, in this case). Save this as src/currency-agent.ts:

// src/currency-agent.ts
// A2A sub-agent: converts an amount between currencies using a fixed demo rate table.
import express from "express";
import { v4 as uuidv4 } from "uuid";
import type { AgentCard, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, Message } from "@a2a-js/sdk";
import {
  AgentExecutor,
  RequestContext,
  ExecutionEventBus,
  DefaultRequestHandler,
  InMemoryTaskStore,
} from "@a2a-js/sdk/server";
import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from "@a2a-js/sdk/server/express";

const PORT = 4001;

// Fixed demo rates (units of USD per 1 unit of the given currency). NOT live FX data —
// swap this for a real FX API (e.g. exchangerate.host) in production.
const RATES_TO_USD: Record<string, number> = {
  EUR: 1.08,
  GBP: 1.27,
  JPY: 0.0067,
  USD: 1,
};

const currencyAgentCard: AgentCard = {
  name: "Currency Agent",
  description: "Converts an amount from a supported currency into USD using a fixed reference rate table.",
  protocolVersion: "0.3.0",
  version: "0.1.0",
  url: `http://localhost:${PORT}/a2a`,
  skills: [
    {
      id: "convert-currency",
      name: "Convert Currency",
      description: "Convert an amount from EUR, GBP, or JPY into USD.",
      tags: ["finance", "currency"],
    },
  ],
  capabilities: {
    streaming: true,
    pushNotifications: false,
  },
  defaultInputModes: ["text"],
  defaultOutputModes: ["text"],
};

class CurrencyExecutor implements AgentExecutor {
  async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
    const { taskId, contextId, userMessage } = requestContext;

    const initialTask: Task = {
      kind: "task",
      id: taskId,
      contextId,
      status: { state: "submitted", timestamp: new Date().toISOString() },
      history: [userMessage],
    };
    eventBus.publish(initialTask);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "working", timestamp: new Date().toISOString() },
      final: false,
    } satisfies TaskStatusUpdateEvent);

    const textPart = userMessage.parts.find((p) => p.kind === "text");
    const text = textPart && textPart.kind === "text" ? textPart.text : "";
    const match = /convert\s+([\d.]+)\s+([A-Z]{3})\s+to\s+([A-Z]{3})/i.exec(text);

    const amountStr = match?.[1];
    const from = match?.[2];
    const to = match?.[3];

    if (!amountStr || !from || !to) {
      this.fail(eventBus, taskId, contextId, 'Could not parse request. Expected: "convert <amount> <FROM> to <TO>".');
      return;
    }

    const amount = Number(amountStr);
    const fromRate = RATES_TO_USD[from.toUpperCase()];
    const toRate = RATES_TO_USD[to.toUpperCase()];

    if (fromRate === undefined || toRate === undefined) {
      this.fail(eventBus, taskId, contextId, `Unsupported currency. Supported: ${Object.keys(RATES_TO_USD).join(", ")}.`);
      return;
    }

    const converted = Math.round(amount * fromRate * (1 / toRate) * 100) / 100;

    eventBus.publish({
      kind: "artifact-update",
      taskId,
      contextId,
      artifact: {
        artifactId: "conversion-result",
        name: "conversion.json",
        parts: [
          {
            kind: "data",
            data: { amount, from: from.toUpperCase(), to: to.toUpperCase(), converted },
          },
        ],
      },
    } satisfies TaskArtifactUpdateEvent);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "completed", timestamp: new Date().toISOString() },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  private fail(eventBus: ExecutionEventBus, taskId: string, contextId: string, reason: string): void {
    const failMessage: Message = {
      kind: "message",
      messageId: uuidv4(),
      role: "agent",
      contextId,
      parts: [{ kind: "text", text: reason }],
    };
    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "failed", timestamp: new Date().toISOString(), message: failMessage },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  async cancelTask(): Promise<void> {
    // Stateless, single-step task — nothing to clean up.
  }
}

const requestHandler = new DefaultRequestHandler(
  currencyAgentCard,
  new InMemoryTaskStore(),
  new CurrencyExecutor()
);

const app = express();
app.use("/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: requestHandler }));
app.use("/a2a", jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));

app.listen(PORT, () => {
  console.log(`Currency Agent listening on http://localhost:${PORT}`);
});

A few things worth calling out. The AgentCard is required to declare name, description, protocolVersion, url, version, capabilities, and the default input/output modes; skills describe what the agent can actually do, and each skill needs id, name, description, and tags4. The executor publishes a Task in the submitted state, moves it to working, then either publishes an artifact-update followed by a completed status, or fails with a failed status carrying an explanatory message — that four-state shape (submittedworkingcompleted/failed) is the entire A2A task lifecycle for a single-turn exchange. eventBus.finished() closes out the executor's turn regardless of which branch it took.

Step 3: Verify the Sub-Agent with curl and the Agent Card

Run the agent and check its Agent Card before writing a single line of client code:

npx tsx src/currency-agent.ts

In a second terminal:

curl -s http://localhost:4001/.well-known/agent-card.json

This is real, captured output from that exact command:

{"name":"Currency Agent","description":"Converts an amount from a supported currency into USD using a fixed reference rate table.","protocolVersion":"0.3.0","version":"0.1.0","url":"http://localhost:4001/a2a","skills":[{"id":"convert-currency","name":"Convert Currency","description":"Convert an amount from EUR, GBP, or JPY into USD.","tags":["finance","currency"]}],"capabilities":{"streaming":true,"pushNotifications":false},"defaultInputModes":["text"],"defaultOutputModes":["text"]}

Now send a raw JSON-RPC message/send request — the same wire format the SDK's client sends under the hood, useful when you want to see exactly what crosses the network before trusting an abstraction:

curl -s -X POST http://localhost:4001/a2a \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "message/send",
    "params": {
      "message": {
        "messageId": "demo-msg-1",
        "role": "user",
        "kind": "message",
        "parts": [{ "kind": "text", "text": "convert 150 EUR to USD" }]
      }
    }
  }'

Real captured response (reformatted for readability):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "kind": "task",
    "id": "0b86b0f0-8a7f-4554-a830-689b89ee0647",
    "contextId": "2c42dad9-b982-4abc-bdc1-48ed87e487e2",
    "status": { "state": "completed", "timestamp": "2026-07-16T04:19:55.669Z" },
    "history": [
      {
        "messageId": "demo-msg-1",
        "role": "user",
        "kind": "message",
        "parts": [{ "kind": "text", "text": "convert 150 EUR to USD" }],
        "contextId": "2c42dad9-b982-4abc-bdc1-48ed87e487e2",
        "taskId": "0b86b0f0-8a7f-4554-a830-689b89ee0647"
      }
    ],
    "artifacts": [
      {
        "artifactId": "conversion-result",
        "name": "conversion.json",
        "parts": [
          { "kind": "data", "data": { "amount": 150, "from": "EUR", "to": "USD", "converted": 162 } }
        ]
      }
    ]
  }
}

150 * 1.08 = 162, so the artifact's converted field checks out against the fixed rate table in the code. The method field is required to be the exact literal string "message/send" — that's not a convention, it's part of the generated SendMessageRequest type in the SDK itself4.

Step 4: Build the Trip Budget Orchestrator (A2A Client + Server)

This is where the "multi-agent" part happens. The Trip Budget Agent is itself an A2A server — it has its own Agent Card and its own executor — but inside that executor, it acts as an A2A client to the Currency Agent. One agent calling another agent over A2A, with the calling agent also being independently reachable as an agent, is the concrete shape of the orchestrator/sub-agent pattern in an A2A system. It's worth being precise about what's actually going on here: A2A itself doesn't define agent hierarchies or a sub-agent concept — its own documentation is explicit that it is "not a sub-agent or tool-call protocol" and doesn't specify how an agent talks to its own sub-agents1. What you're building is an application-level pattern — delegation — implemented using A2A's peer-to-peer task exchange as the transport. It's the same pattern Google's own reference examples use for cross-language pipelines, like a Python agent that extracts contract terms and hands validation to a separate Go agent over A2A6.

Save this as src/trip-agent.ts:

// src/trip-agent.ts
// A2A orchestrator agent: parses an expense message, DELEGATES the currency conversion
// to the Currency Agent over A2A (acting as an A2A client from inside its own executor),
// then returns a budget summary. This is the orchestrator/sub-agent pattern built ON TOP
// of A2A's peer-to-peer task delegation -- A2A itself does not define agent hierarchies,
// it just gives two peer agents a common way to exchange a Task.
import express from "express";
import { v4 as uuidv4 } from "uuid";
import type { AgentCard, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, Message } from "@a2a-js/sdk";
import {
  AgentExecutor,
  RequestContext,
  ExecutionEventBus,
  DefaultRequestHandler,
  InMemoryTaskStore,
} from "@a2a-js/sdk/server";
import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from "@a2a-js/sdk/server/express";
import { ClientFactory } from "@a2a-js/sdk/client";

const PORT = 4000;
const CURRENCY_AGENT_URL = "http://localhost:4001";

const tripAgentCard: AgentCard = {
  name: "Trip Budget Agent",
  description: "Logs a trip expense and converts it to USD by delegating to the Currency Agent over A2A.",
  protocolVersion: "0.3.0",
  version: "0.1.0",
  url: `http://localhost:${PORT}/a2a`,
  skills: [
    {
      id: "log-expense",
      name: "Log Expense",
      description: 'Log a foreign-currency expense, e.g. "I spent 150 EUR on hotel".',
      tags: ["finance", "travel", "orchestrator"],
    },
  ],
  capabilities: {
    streaming: true,
    pushNotifications: false,
  },
  defaultInputModes: ["text"],
  defaultOutputModes: ["text"],
};

class TripBudgetExecutor implements AgentExecutor {
  // One client, reused across requests, targeting the Currency Agent's base URL.
  private currencyClientFactory = new ClientFactory();

  async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
    const { taskId, contextId, userMessage } = requestContext;

    const initialTask: Task = {
      kind: "task",
      id: taskId,
      contextId,
      status: { state: "submitted", timestamp: new Date().toISOString() },
      history: [userMessage],
    };
    eventBus.publish(initialTask);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "working", timestamp: new Date().toISOString() },
      final: false,
    } satisfies TaskStatusUpdateEvent);

    const textPart = userMessage.parts.find((p) => p.kind === "text");
    const text = textPart && textPart.kind === "text" ? textPart.text : "";
    const match = /spent\s+([\d.]+)\s+([A-Z]{3})\s+on\s+(.+)/i.exec(text);

    const amountStr = match?.[1];
    const currency = match?.[2];
    const item = match?.[3];

    if (!amountStr || !currency || !item) {
      this.fail(eventBus, taskId, contextId, 'Could not parse request. Expected: "I spent <amount> <CUR> on <item>".');
      return;
    }

    // --- Delegate to the Currency Agent as an A2A client. ---
    let convertedAmount: number;
    try {
      const currencyClient = await this.currencyClientFactory.createFromUrl(CURRENCY_AGENT_URL);
      const delegateResult = await currencyClient.sendMessage({
        message: {
          messageId: uuidv4(),
          role: "user",
          parts: [{ kind: "text", text: `convert ${amountStr} ${currency.toUpperCase()} to USD` }],
          kind: "message",
        },
      });

      if (delegateResult.kind !== "task") {
        this.fail(eventBus, taskId, contextId, "Currency Agent did not return a task.");
        return;
      }
      const delegateTask = delegateResult as Task;
      if (delegateTask.status.state !== "completed" || !delegateTask.artifacts?.length) {
        this.fail(eventBus, taskId, contextId, `Currency Agent could not convert ${amountStr} ${currency}.`);
        return;
      }
      const dataPart = delegateTask.artifacts[0]?.parts.find((p) => p.kind === "data");
      if (!dataPart || dataPart.kind !== "data") {
        this.fail(eventBus, taskId, contextId, "Currency Agent artifact had no data part.");
        return;
      }
      convertedAmount = (dataPart.data as { converted: number }).converted;
    } catch (err) {
      this.fail(eventBus, taskId, contextId, `Could not reach Currency Agent: ${(err as Error).message}`);
      return;
    }

    const summary = `${item.trim()}: ${amountStr} ${currency.toUpperCase()} -> $${convertedAmount.toFixed(2)} USD`;

    eventBus.publish({
      kind: "artifact-update",
      taskId,
      contextId,
      artifact: {
        artifactId: "budget-line",
        name: "budget-line.txt",
        parts: [{ kind: "text", text: summary }],
      },
    } satisfies TaskArtifactUpdateEvent);

    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "completed", timestamp: new Date().toISOString() },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  private fail(eventBus: ExecutionEventBus, taskId: string, contextId: string, reason: string): void {
    const failMessage: Message = {
      kind: "message",
      messageId: uuidv4(),
      role: "agent",
      contextId,
      parts: [{ kind: "text", text: reason }],
    };
    eventBus.publish({
      kind: "status-update",
      taskId,
      contextId,
      status: { state: "failed", timestamp: new Date().toISOString(), message: failMessage },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    eventBus.finished();
  }

  async cancelTask(): Promise<void> {
    // Single-hop delegation completes quickly enough that we don't track cancellation state.
  }
}

const requestHandler = new DefaultRequestHandler(
  tripAgentCard,
  new InMemoryTaskStore(),
  new TripBudgetExecutor()
);

const app = express();
app.use("/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: requestHandler }));
app.use("/a2a", jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));

app.listen(PORT, () => {
  console.log(`Trip Budget Agent listening on http://localhost:${PORT}`);
});

Notice that TripBudgetExecutor uses the exact same ClientFactory API you'll use in a standalone client script in the next step. From the SDK's point of view, there's no difference between "a human-facing app talking to an agent" and "an agent talking to another agent" — both are just A2A clients calling sendMessage against a base URL. That symmetry is what makes the orchestrator pattern easy to build: your orchestrator's executor is simultaneously an A2A server (to whatever calls it) and an A2A client (to whatever it calls).

Step 5: Run the Full Multi-Agent System

Start both agents, then run a client script that talks only to the Trip Budget Agent — it never talks to the Currency Agent directly, because it doesn't need to.

Save this as src/client-demo.ts:

// src/client-demo.ts
// Stands in for a human-facing app: discovers the Trip Budget Agent's Agent Card,
// sends it an expense, and prints the delegated result. Then repeats the call using
// the streaming API to show live task-lifecycle events.
import { ClientFactory } from "@a2a-js/sdk/client";
import type { Message, MessageSendParams, Task } from "@a2a-js/sdk";
import { v4 as uuidv4 } from "uuid";

const TRIP_AGENT_URL = "http://localhost:4000";

async function sendOnce() {
  const factory = new ClientFactory();
  const client = await factory.createFromUrl(TRIP_AGENT_URL);

  const params: MessageSendParams = {
    message: {
      messageId: uuidv4(),
      role: "user",
      parts: [{ kind: "text", text: "I spent 150 EUR on hotel" }],
      kind: "message",
    },
  };

  const result = await client.sendMessage(params);

  if (result.kind === "task") {
    const task = result as Task;
    console.log(`[sendMessage] Task ${task.id} finished with status: ${task.status.state}`);
    const textPart = task.artifacts?.[0]?.parts.find((p) => p.kind === "text");
    if (textPart && textPart.kind === "text") {
      console.log(`[sendMessage] Artifact: ${textPart.text}`);
    }
  } else {
    const message = result as Message;
    const textPart = message.parts.find((p) => p.kind === "text");
    console.log("[sendMessage] Direct message:", textPart && textPart.kind === "text" ? textPart.text : message);
  }
}

async function streamOnce() {
  const factory = new ClientFactory();
  const client = await factory.createFromUrl(TRIP_AGENT_URL);

  const params: MessageSendParams = {
    message: {
      messageId: uuidv4(),
      role: "user",
      parts: [{ kind: "text", text: "I spent 42 GBP on taxi" }],
      kind: "message",
    },
  };

  const stream = client.sendMessageStream(params);
  for await (const event of stream) {
    if (event.kind === "task") {
      console.log(`[stream] Task ${event.id} created. Status: ${event.status.state}`);
    } else if (event.kind === "status-update") {
      console.log(`[stream] Task ${event.taskId} status -> ${event.status.state}`);
    } else if (event.kind === "artifact-update") {
      const textPart = event.artifact.parts.find((p) => p.kind === "text");
      console.log(`[stream] Artifact received: ${event.artifact.name}${textPart && textPart.kind === "text" ? ` ("${textPart.text}")` : ""}`);
    }
  }
  console.log("[stream] --- stream finished ---");
}

async function main() {
  console.log("=== Blocking call (sendMessage) ===");
  await sendOnce();
  console.log("\n=== Streaming call (sendMessageStream) ===");
  await streamOnce();
}

await main();

Start both servers in separate terminals, then run the client:

# terminal 1
npx tsx src/currency-agent.ts
# terminal 2
npx tsx src/trip-agent.ts
# terminal 3
npx tsx src/client-demo.ts

This is the real, unedited output from running that exact sequence:

=== Blocking call (sendMessage) ===
[sendMessage] Task c10d0b3d-b875-4d81-8a6a-fdedc326fc3e finished with status: completed
[sendMessage] Artifact: hotel: 150 EUR -> $162.00 USD

=== Streaming call (sendMessageStream) ===
[stream] Task 7719ebd8-18a9-43fa-8dd1-05489c6c69b2 created. Status: submitted
[stream] Task 7719ebd8-18a9-43fa-8dd1-05489c6c69b2 status -> working
[stream] Artifact received: budget-line.txt ("taxi: 42 GBP -> $53.34 USD")
[stream] Task 7719ebd8-18a9-43fa-8dd1-05489c6c69b2 status -> completed
[stream] --- stream finished ---

Both numbers check out: 150 EUR * 1.08 = $162.00, and 42 GBP * 1.27 = $53.34. The task IDs are random UUIDs generated fresh on each run, so yours will differ — everything else should match exactly.

Step 6: What the Streaming Call Actually Shows You

The blocking sendMessage call in sendOnce() only returns once the Trip Budget Agent's executor has finished — you get the final Task object with its completed status and artifact, and nothing in between. The streaming call in streamOnce() uses sendMessageStream, which returns an AsyncGenerator over Server-Sent Events, so you see every event the executor publishes to its ExecutionEventBus as it happens: the initial task creation event, each status-update (here just working then completed, since this executor's chain of work finishes quickly), and the artifact-update carrying the budget line47.

This matters once an agent's work takes longer than a request-response cycle is comfortable with — a multi-step research task, a long-running data pipeline, anything where a caller (human or agent) wants to show progress instead of a blank spinner. A2A's specification explicitly gives clients a choice here: they can poll, stream, or register a webhook for push notifications, depending on what the workload and the caller's own architecture need8. This tutorial's agents are fast enough that streaming versus blocking barely matters functionally, but the wiring is identical for an executor whose execute() method takes thirty seconds instead of thirty milliseconds — you'd just publish more status-update events along the way.

A2A vs MCP: When to Use Each

If you've used the Model Context Protocol (MCP), it's worth being precise about how it relates to A2A, because the two get conflated constantly. The protocol's own documentation draws the line clearly: MCP standardizes how a single agent connects to its own tools, APIs, and data sources; A2A standardizes how independent agents discover each other and communicate across organizational or platform boundaries1. A practical rule of thumb: if you're giving one agent access to a database, a search index, or a GitHub repo, that's an MCP problem — see our walkthrough of building an MCP client with stdio transport9. If you're letting two independently-deployed agents — possibly built on completely different frameworks, by different teams — exchange a task and a result, that's an A2A problem. In practice, the two are frequently used together: MCP inside an agent, A2A between agents8.

Verification

What was actually run for this tutorial, and what wasn't:

  • Executed for real: all three files above compiled cleanly with tsc --strict --noUncheckedIndexedAccess --noEmit against the real installed @a2a-js/sdk@0.3.14. Both servers were started with tsx, and client-demo.ts was run against them — the terminal output in Step 5 is copied verbatim from that run, not reconstructed. The raw curl calls in Step 3 were run against the live Currency Agent process.
  • Not executed: no live LLM call. Both executors use deterministic parsing and a fixed lookup table specifically so the tutorial runs with zero external dependencies and zero API keys. If you wire a real model into either executor's execute() method, the A2A server/client plumbing around it doesn't change.

To confirm your own setup works, run the two curl commands from Step 3 and check that the Currency Agent's card lists "protocolVersion":"0.3.0" and that converting 150 EUR returns "converted":162.

Troubleshooting

Error: listen EADDRINUSE: address already in use :::4000 (or :4001) — one of the two agent processes is already running from an earlier attempt. Find and stop it (lsof -i :4000 on macOS/Linux, then kill <pid>), or change PORT in the corresponding file.

Client hangs or throws a connection error against createFromUrlClientFactory.createFromUrl fetches /.well-known/agent-card.json from the base URL you pass it before it can send anything. If the target agent isn't running yet, start it first; if you changed a port, make sure CURRENCY_AGENT_URL in trip-agent.ts and TRIP_AGENT_URL in client-demo.ts were updated to match.

Currency Agent did not return a task. — this is the orchestrator's own error path firing, which means the Currency Agent responded but with a direct Message instead of a Task. In this tutorial that only happens if CurrencyExecutor is modified to skip publishing the initial Task object — the fix is to make sure eventBus.publish(initialTask) still runs before any other event.

Cannot find module '@a2a-js/sdk/server/express' — the Express integration is a separate subpath export, and Express itself is a peer dependency, not a transitive one7. If you see this, run npm install express explicitly even though @a2a-js/sdk is already installed.

TypeScript complains that a regex capture group is possibly undefined — this happens under --strict --noUncheckedIndexedAccess (used throughout this tutorial) because TypeScript can't prove a successful regex match populated every capture group, even when the pattern has no optional groups. Both executors handle this by destructuring with match?.[1] and guarding with an explicit if (!amountStr || ...) check before using the values — don't reach for a non-null assertion (!) instead, since it silently defeats the same safety check noUncheckedIndexedAccess exists to provide.

Choosing Between the Stable SDK and the v1.0 Beta

One more thing worth deciding deliberately rather than by accident: A2A the protocol reached its first stable specification, v1.0, on March 12, 2026, shipping signed Agent Cards, multi-tenancy support, and a defined migration path from v0.3810. The Linux Foundation's one-year adoption retrospective, published about four weeks later, confirmed strong enterprise uptake of the new spec11. But @a2a-js/sdk's latest dist-tag on npm — the one this tutorial installs — is still 0.3.14, implementing the v0.3 specification; v1.0 support currently lives on the package's next tag as 1.0.0-beta.0, published about two weeks before this tutorial was written4. The protocol's own announcement is candid about this gap, noting the community was still "focused on delivering multi-language v1.0 SDK support" even as the spec itself shipped8. For a tutorial — or a project you're starting today — pinning to the stable 0.3.14 release is the safer default; track the next tag if you specifically need v1.0's multi-tenancy or Signed Agent Card features and can tolerate breaking changes as the JS SDK's own v1.0 support matures.

Next Steps

From here, a natural extension is giving one of these agents real intelligence instead of a regex parser — wire CurrencyExecutor.execute() or TripBudgetExecutor.execute() up to an LLM tool-calling loop, using the same agentic-loop pattern from our raw Messages API tutorial3, and the A2A layer around it doesn't need to change at all. If your agents need to call external tools (a database, a search API, a file system) rather than each other, that's MCP's job, not A2A's — see the MCP client tutorial for the stdio transport walkthrough9. And if an orchestrator like this one is going to take real-world actions on a user's behalf — spending money, sending messages, modifying data — pair it with human-in-the-loop approval gates before it ships12.

Footnotes

  1. A2A Protocol homepage — "How A2A Works with MCP" and "What A2A Is Not" — a2a-protocol.org, accessed 2026-07-16. 2 3 4

  2. Node.js — endoflife.date — accessed 2026-07-16, page last updated 2026-07-14. 2

  3. Claude Tool Use in TypeScript: Agentic Loop Tutorial (2026) 2

  4. @a2a-js/sdk on npm — version 0.3.14, published 2026-07-09. 2 3 4 5

  5. A2A Protocol Specification v0.3 — a2a-protocol.org.

  6. Build a Cross-Language Multi-Agent Team with Google's Agent Development Kit and A2A — Google Developers Blog.

  7. a2aproject/a2a-js — Official JavaScript SDK for the Agent2Agent (A2A) Protocol — GitHub, accessed 2026-07-16. 2

  8. Announcing Version 1.0 — A2A Protocol — a2a-protocol.org, accessed 2026-07-16. 2 3 4

  9. Build an MCP Client in TypeScript: A 2026 Tutorial 2

  10. A2A v1.0.0 release — GitHub, released 2026-03-12. The Linux Foundation's April 9, 2026 announcement11 is a one-year adoption retrospective published about four weeks after the spec itself shipped, not the release date.

  11. A2A Protocol Surpasses 150 Organizations, Lands in Major Cloud Platforms, and Sees Enterprise Production Use in First Year — The Linux Foundation, April 9, 2026. 150+ supporting organizations, 22,000+ GitHub stars on the core spec repository, SDKs in five production-ready languages. 2

  12. Add Human-in-the-Loop Approval to Claude Agent SDK (2026)