# Subagents vs A2A: One Is a Pattern, One Is a Protocol

I get asked to compare these two often enough that it is worth writing down properly. The short answer is that subagents and A2A are not competing, and treating them as alternatives leads people to build the wrong thing.

**Subagents are a way to structure one agent's internals. A2A is a way for two agents that do not know each other to work together over a network.** One is an implementation pattern. The other is a wire protocol. You can use both in the same system, and in a lot of real deployments you should.

## what a subagent actually is

A subagent is a child agent that a parent agent spawns inside its own process. The parent writes the child's system prompt, decides which tools it may call, usually picks its model, and receives its output back into its own context.

Stripped of framework specifics, the pattern looks like this:

```typescript
type Subagent = {
  name: string;
  systemPrompt: string;
  allowedTools: string[];
};

const researcher: Subagent = {
  name: 'researcher',
  systemPrompt: 'Search the codebase and summarise what you find. Do not edit files.',
  allowedTools: ['read_file', 'grep'],
};

async function runSubagent(agent: Subagent, instruction: string): Promise<string> {
  const result = await model.run({
    system: agent.systemPrompt,
    tools: toolRegistry.only(agent.allowedTools),
    messages: [{ role: 'user', content: instruction }],
  });

  return result.text;
}

// The parent calls it like a tool and folds the answer back into its own reasoning.
const findings = await runSubagent(researcher, 'Where do we validate JWTs?');
```

Notice what the parent controls here. Everything. The prompt, the tool allowlist, the model, the error handling, the retry policy. The child gets a fresh context window, does one scoped job, and returns a string.

That is the point. **Subagents exist to solve context economy and specialisation.** A long task that would otherwise fill one context window gets split across several, each of which only sees what it needs. Nothing about this is standardised.

Claude Code defines subagents as markdown files in `.claude/agents/`. LangGraph uses graph nodes. CrewAI uses roles. None of them interoperate, and none of them need to, because you own both sides.

## where subagents stop working

Subagents assume a shared trust domain. The parent can read the child's output, inspect its intermediate state, change its prompt, and revoke its tools. All of this works because everything is running in one process that one team deploys.

Now change one thing. The specialist you want to delegate to is run by a different company.

You cannot write its system prompt. You cannot see its tools. You do not know what model it uses, and you should not care. It will not return a string in 200 milliseconds, because its work involves a human scanning something on a phone. It might take four minutes or four hours. It needs to authenticate you, and you need to authenticate it. If it goes down mid-task, you need a way to reconnect and find out what happened.

None of that is a subagent problem. That is a distributed systems problem, and it is exactly the problem A2A was written to solve.

%[https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/] 

## the A2A mental model

A2A is an open standard, originally announced by Google in April 2025 and transferred to the Linux Foundation in June 2025. It reached v1.0 in 2026. The technical steering committee includes AWS, Cisco, Google, IBM Research, Microsoft, Salesforce, SAP, and ServiceNow.

The single most important word in the specification is **opaque**. A2A is designed for agents that expose what they can do without exposing how they do it. The remote agent advertises capabilities and returns results. It never shows you its prompts, its memory, its tools, or its internal reasoning. You are not orchestrating it. You are asking it for something.

If you internalise that one idea, most of the design decisions in the protocol follow naturally.

### the four primitives

![](https://cdn.hashnode.com/uploads/covers/63b9794295025da0eecd94f7/d4cc7a7d-f0c6-4f3a-a81b-cd0eaaa2d84b.png align="center")

**Agent Card.** A JSON document, served at a well known URL, describing who the agent is, what skills it offers, which transports it speaks, and what authentication it expects. This is how discovery works. You fetch a card before you send anything.

**Message.** The interaction payload. What you send, and what conversational replies come back. Messages are made of parts, which can be text, files, or structured data.

**Task.** The durable unit of work. Anything non-trivial creates a Task with an ID and a state machine. Tasks survive disconnection. You can query them, cancel them, and resubscribe to them.

**Artifact.** The output payload. When a task produces something, a report, a file, a signed attestation, it comes back as an artifact attached to the task rather than as a chat message.

The Message and Task split trips people up initially. The rule of thumb: if the agent can answer immediately and statelessly, it returns a Message. If the work has duration or state, it returns a Task. Your client needs to handle both.

### transport

A2A communication must happen over HTTP or HTTPS. The specification defines three transport bindings,

1.  JSON-RPC
    
2.  HTTP+JSON/REST,
    
3.  and gRPC.
    

All three are equal in status. An agent must implement at least one and may implement several, advertising each in its card. The client picks whichever it supports.

## building an A2A server in TypeScript

The running example for the rest of this article is a verification agent. An onboarding agent at Company A wants to confirm that a new signup is a unique human. It delegates that to a verification agent run by Company B. Company A cannot see how the check works. Company B will not return an answer instantly, because a person has to scan a QR code on their phone.

*This is a good A2A example precisely because it is a bad subagent example.*

### installing

```shell
npm install @a2a-js/sdk express uuid
npm install -D @types/express @types/uuid typescript
```

**A note on versions before you copy anything.** The protocol specification is at v1.0, but the JavaScript SDK is not there yet. The latest stable release is `v0.3.13`, which implements protocol v0.3. A v1.0 alpha exists behind `npm install @a2a-js/sdk@next`. The code below targets the stable line.

### step 1: write the agent card

The card is your public interface. Everything a client knows about you before it sends a byte comes from here.

```typescript
// agent-card.ts
import { AgentCard } from '@a2a-js/sdk';

export const verificationAgentCard: AgentCard = {
  name: 'Personhood Verification Agent',
  description: 'Confirms that a session belongs to a unique human and returns a signed attestation.',
  protocolVersion: '0.3.0',
  version: '1.2.0',
  url: 'https://verify.example.com/a2a/jsonrpc',

  provider: {
    organization: 'Example Verification Co',
    url: 'https://example.com',
  },

  capabilities: {
    streaming: true,
    pushNotifications: true,
    stateTransitionHistory: true,
  },

  defaultInputModes: ['text'],
  defaultOutputModes: ['text'],

  skills: [
    {
      id: 'verify-personhood',
      name: 'Verify personhood',
      description:
        'Issues a verification challenge and confirms the subject is a unique human. ' +
        'Returns an attestation artifact on success.',
      tags: ['identity', 'verification', 'proof-of-personhood'],
      examples: ['Verify the human behind session sess_8fa21c'],
    },
  ],

  additionalInterfaces: [
    { url: 'https://verify.example.com/a2a/jsonrpc', transport: 'JSONRPC' },
    { url: 'https://verify.example.com/a2a/rest', transport: 'HTTP+JSON' },
  ],
};
```

Write the skill descriptions for a machine reader, not a marketing page. A client agent decides whether to route work to you by reading this text. Vagueness here costs you traffic.

### step 2: implement the executor

The `AgentExecutor` is where your logic lives. You receive a request context and an event bus, and you publish events as the work progresses.

```typescript
// executor.ts
import { v4 as uuidv4 } from 'uuid';
import { Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from '@a2a-js/sdk';
import { AgentExecutor, RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';

export class VerificationExecutor implements AgentExecutor {
  private cancelled = new Set<string>();

  async cancelTask(taskId: string): Promise<void> {
    this.cancelled.add(taskId);
  }

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

    // 1. Create the task on first contact.
    if (!task) {
      const initial: Task = {
        kind: 'task',
        id: taskId,
        contextId,
        status: { state: 'submitted', timestamp: new Date().toISOString() },
        history: [userMessage],
      };
      bus.publish(initial);
    }

    // 2. Move to working while we mint a challenge.
    bus.publish({
      kind: 'status-update',
      taskId,
      contextId,
      status: { state: 'working', timestamp: new Date().toISOString() },
      final: false,
    } satisfies TaskStatusUpdateEvent);

    const sessionId = extractSessionId(userMessage);
    const challenge = await createVerificationChallenge(sessionId);

    // 3. We cannot finish without a human. Hand control back.
    const awaitingUser: TaskStatusUpdateEvent = {
      kind: 'status-update',
      taskId,
      contextId,
      status: {
        state: 'input-required',
        timestamp: new Date().toISOString(),
        message: {
          kind: 'message',
          messageId: uuidv4(),
          role: 'agent',
          taskId,
          contextId,
          parts: [
            {
              kind: 'text',
              text: `Have the user scan this to continue: ${challenge.qrUrl}`,
            },
          ],
        },
      },
      final: true,
    };

    bus.publish(awaitingUser);
    bus.finished();
  }
}
```

Step 3 is the part I want to draw attention to, because it has no equivalent in the subagent world.

`input-required` is a first class task state. The agent is saying: *I have done what I can, I am blocked on a human, here is what they need to do, come back to me. The task is not failed. It is not complete. It is parked, and it keeps its ID.*

A subagent cannot do this. A function call cannot do this. The closest you get without a protocol is inventing your own polling convention and documenting it in a PDF that your partner will misread. **A2A gives you the state machine for free, and every compliant client already knows what to do with it.**

### step 3: resume when the human is done

When the client sends a follow up message on the same task, `execute` runs again, this time with `task` populated.

```typescript
// executor.ts, continued inside VerificationExecutor
private async resume(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
  const { taskId, contextId } = ctx;

  if (this.cancelled.has(taskId)) {
    bus.publish({
      kind: 'status-update',
      taskId,
      contextId,
      status: { state: 'canceled', timestamp: new Date().toISOString() },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    bus.finished();
    this.cancelled.delete(taskId);
    return;
  }

  const proof = await fetchCompletedProof(taskId);

  if (!proof) {
    bus.publish({
      kind: 'status-update',
      taskId,
      contextId,
      status: {
        state: 'failed',
        timestamp: new Date().toISOString(),
        message: {
          kind: 'message',
          messageId: uuidv4(),
          role: 'agent',
          taskId,
          contextId,
          parts: [{ kind: 'text', text: 'Challenge expired before completion.' }],
        },
      },
      final: true,
    } satisfies TaskStatusUpdateEvent);
    bus.finished();
    return;
  }

  // Deliver the result as an artifact, not as chat text.
  bus.publish({
    kind: 'artifact-update',
    taskId,
    contextId,
    artifact: {
      artifactId: 'attestation',
      name: 'personhood-attestation.json',
      parts: [{ kind: 'text', text: JSON.stringify(proof, null, 2) }],
    },
  } satisfies TaskArtifactUpdateEvent);

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

  bus.finished();
}
```

Results go in artifacts. Conversation goes in messages. Keeping that separation clean is what lets a client process your output programmatically instead of regex parsing prose.

### step 4: wire up the transports

```typescript
// 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 { verificationAgentCard } from './agent-card';
import { VerificationExecutor } from './executor';

const requestHandler = new DefaultRequestHandler(
  verificationAgentCard,
  new InMemoryTaskStore(),
  new VerificationExecutor(),
);

const app = express();

app.use(`/${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: requestHandler }));
app.use('/a2a/jsonrpc', jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
app.use('/a2a/rest', restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));

app.listen(4000, () => console.log('Verification agent listening on :4000'));
```

`InMemoryTaskStore` is fine for local development and wrong for production. Tasks are supposed to outlive process restarts. Implement the `TaskStore` interface against Postgres or Redis before you deploy anything real.

`UserBuilder.noAuthentication` is also a development default. Part 7 covers replacing it.

## the client side

### discovery

The client fetches the card first. This is the step that has no analogue in the subagent world, and it is what makes A2A a real integration protocol rather than a calling convention.

```typescript
// client.ts
import { ClientFactory } from '@a2a-js/sdk/client';
import { MessageSendParams, Task, Message } from '@a2a-js/sdk';
import { v4 as uuidv4 } from 'uuid';

const factory = new ClientFactory();

// Defaults to /.well-known/agent-card.json
const client = await factory.createFromUrl('https://verify.example.com');
```

Before sending work, check that the agent can actually do the thing:

```typescript
const card = await client.getAgentCard();

const canVerify = card.skills.some((s) => s.id === 'verify-personhood');
if (!canVerify) {
  throw new Error(`${card.name} does not offer personhood verification`);
}

if (!card.capabilities.pushNotifications) {
  console.warn('No push support. Falling back to streaming.');
}
```

This is capability negotiation, and it is the reason A2A scales past bilateral integrations. You are not hardcoding an assumption about what the peer does. You are reading it at runtime and adapting.

### sending a message

```typescript
const params: MessageSendParams = {
  message: {
    kind: 'message',
    messageId: uuidv4(),
    role: 'user',
    parts: [{ kind: 'text', text: 'Verify the human behind session sess_8fa21c' }],
  },
};

const result = await client.sendMessage(params);

if (result.kind === 'task') {
  const task = result as Task;
  console.log(`Task ${task.id} is ${task.status.state}`);

  if (task.status.state === 'input-required') {
    // Surface the instruction to your own user.
    console.log(task.status.message?.parts[0]);
  }
} else {
  const message = result as Message;
  console.log('Direct reply:', message.parts[0]);
}
```

Always branch on `result.kind`. An agent is allowed to answer either way, and assuming you always get a Task is one of the more common integration bugs.

### streaming

For work with visible intermediate progress, `sendMessageStream` returns an async generator of events.

```typescript
const stream = client.sendMessageStream(params);

for await (const event of stream) {
  switch (event.kind) {
    case 'task':
      console.log(`Task opened: ${event.id}`);
      break;
    case 'status-update':
      console.log(`State: ${event.status.state}`);
      if (event.status.state === 'input-required') {
        console.log('Waiting on the user:', event.status.message?.parts[0]);
      }
      break;
    case 'artifact-update':
      console.log(`Artifact: ${event.artifact.name}`);
      break;
  }
}
```

Streaming runs over [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). It is the right choice when your own user is watching a progress indicator and the work finishes in seconds or low minutes.

## work that outlives the connection

Streaming assumes you can hold a connection open. That assumption breaks on serverless functions, mobile clients, and anything that takes hours. A2A's answer is push notifications to a webhook you control.

Declare support on the server card:

```json
capabilities: {
  streaming: true,
  pushNotifications: true,
  stateTransitionHistory: true,
},
```

Register a web-hook when sending:

```typescript
import { PushNotificationConfig } from '@a2a-js/sdk';

const pushConfig: PushNotificationConfig = {
  id: 'onboarding-verifications',
  url: 'https://onboarding.example.com/webhooks/a2a',
  token: process.env.WEBHOOK_SHARED_SECRET,
};

await client.sendMessage({
  message: {
    kind: 'message',
    messageId: uuidv4(),
    role: 'user',
    parts: [{ kind: 'text', text: 'Verify session sess_8fa21c' }],
  },
  configuration: {
    blocking: false,
    acceptedOutputModes: ['text/plain'],
    pushNotificationConfig: pushConfig,
  },
});
```

Receive the callback:

```typescript
app.post('/webhooks/a2a', express.json(), (req, res) => {
  const presented = req.headers['x-a2a-notification-token'];

  if (presented !== process.env.WEBHOOK_SHARED_SECRET) {
    return res.status(401).json({ error: 'unauthorized' });
  }

  const task = req.body;
  console.log(`Task ${task.id} moved to ${task.status.state}`);

  if (task.status.state === 'completed') {
    void completeOnboarding(task);
  }

  res.status(200).json({ received: true });
});
```

Verify that token. An unauthenticated web-hook that mutates onboarding state is an open door, and "the URL is hard to guess" is not authentication.

## authentication across the boundary

Subagents inherit the parent's permissions because they run inside it. A2A agents do not, so every call needs credentials.

The SDK gives you `AuthenticationHandler` plus `createAuthenticatingFetchWithRetry`, which attaches headers and transparently retries once on a 401 after refreshing.

```typescript
import {
  ClientFactory,
  ClientFactoryOptions,
  JsonRpcTransportFactory,
  AuthenticationHandler,
  createAuthenticatingFetchWithRetry,
} from '@a2a-js/sdk/client';

const tokens = {
  current: await mintToken(),
  refresh: async () => {
    tokens.current = await mintToken();
    return tokens.current;
  },
};

const authHandler: AuthenticationHandler = {
  headers: async () => ({
    Authorization: `Bearer ${tokens.current}`,
  }),

  shouldRetryWithHeaders: async (_req: RequestInit, res: Response) => {
    if (res.status === 401) {
      const fresh = await tokens.refresh();
      return { Authorization: `Bearer ${fresh}` };
    }
    return undefined;
  },
};

const authFetch = createAuthenticatingFetchWithRetry(fetch, authHandler);

const factory = new ClientFactory(
  ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
    transports: [new JsonRpcTransportFactory({ fetchImpl: authFetch })],
  }),
);

const client = await factory.createFromUrl('https://verify.example.com');
```

For cross cutting concerns that are not authentication, use a `CallInterceptor`, which is transport agnostic and therefore keeps working if you switch from JSON-RPC to gRPC.

```typescript
import { CallInterceptor, BeforeArgs } from '@a2a-js/sdk/client';

class TracingInterceptor implements CallInterceptor {
  async before(args: BeforeArgs): Promise<void> {
    args.options = {
      ...args.options,
      serviceParameters: {
        ...args.options.serviceParameters,
        'X-Request-ID': uuidv4(),
        'X-Trace-Parent': currentTraceParent(),
      },
    };
  }

  async after(): Promise<void> {}
}
```

## choosing between them

|  | Subagents | A2A |
| --- | --- | --- |
| Boundary | One process | Across processes and organisations |
| Coupling | Tight. You own the prompt, tools, model | Loose. The peer is a black box |
| Discovery | Static configuration | Agent Card fetched at runtime |
| Authentication | Inherited from the parent | Explicit, per request |
| Work model | Request to response within a turn | Long lived, resumable, streamable tasks |
| Human in the loop | You build it yourself | `input-required` is a protocol state |
| Failure handling | Try or catch | Task states plus resubscription |
| Standardised | No, per framework | Yes, vendor neutral |
| Right when | Splitting context, specialising roles | Integrating with someone else's agent |

**The practical decision rule I use:** if you can edit the other agent's system prompt, it is a subagent. If you cannot, you need a protocol.

They compose cleanly. An A2A server can be implemented internally as an orchestrator delegating to subagents, and nobody outside can tell. **A subagent can be an A2A client.** The useful sibling comparison is that MCP connects an agent to tools and resources, A2A connects an agent to other agents, and subagents are how a single agent organises its own interior.

~ Mehta

[https://www.mrmehta.in](https://www.mrmehta.in)
