---
title: "A2A Protocol"
description: "Agent-to-agent communication via JSON-RPC: discovery, messaging, streaming, and task management."
---

# A2A Protocol

A2A lets agents talk to each other directly over HTTP: they discover each other's capabilities, send messages, and get back structured results — no shared database or hand-wired API needed.

<Callout tone="info">

**A2A vs MCP.** A2A is for **agent-to-agent** delegation — one agent-native app's agent handing work to another app's agent, which reasons over it with its own LLM loop. [MCP](/docs/mcp-protocol) is for **tool-to-agent** access — an external host like Claude or Cursor calling your app's actions directly as tools, with no extra agent reasoning in between. Use A2A when the receiving side should think; use MCP when it should just execute. See [MCP Server — MCP vs A2A](/docs/mcp-protocol#mcp-vs-a2a) for the full comparison table.

</Callout>

## Overview {#overview}

A2A (agent-to-agent) is a JSON-RPC protocol for inter-agent communication. A mail agent can ask an analytics agent to run a query. A calendar agent can search issues in a project management agent. Each agent exposes its capabilities via an agent card and accepts work via a standard JSON-RPC endpoint.

A2A is the substrate for cross-app delegation in this framework — most prominently for [Dispatch](/docs/dispatch), which routes a single inbound message (Slack, email, etc.) to whichever app in the workspace is best suited to handle it.

Key concepts:

- **Agent card** — public metadata at `/.well-known/agent-card.json` describing skills and capabilities
- **JSON-RPC** — agent-native apps use `POST /_agent-native/a2a`; external/legacy peers may use `POST /a2a`
- **Tasks** — each message creates a task with a lifecycle (submitted, working, completed, failed, canceled)
- **JWT bearer auth** — production A2A requires `A2A_SECRET` or an explicit legacy `apiKeyEnv`

<Diagram id="doc-block-3a1ebt" title="One agent hands work to another" summary={"A mail agent discovers the analytics agent's card, sends a JSON-RPC message, and gets a completed task back."}>

```html
<div class="diagram-handoff">
  <div class="diagram-card">
    <strong>Mail agent</strong
    ><small class="diagram-muted">needs analytics</small>
  </div>
  <div class="diagram-col">
    <div class="diagram-pill">GET /.well-known/agent-card.json</div>
    <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
    <div class="diagram-pill accent">
      POST /_agent-native/a2a<br /><small class="diagram-muted"
        >message/send</small
      >
    </div>
    <div class="diagram-arrow diagram-muted" aria-hidden="true">&larr;</div>
    <div class="diagram-pill ok">task · completed</div>
  </div>
  <div class="diagram-card" data-rough>
    <strong>Analytics agent</strong
    ><small class="diagram-muted">runs run-query, returns result</small>
  </div>
</div>
```

```css
.diagram-handoff {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
}
.diagram-handoff .diagram-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
.diagram-handoff .diagram-arrow {
  font-size: 20px;
  line-height: 1;
}
```

</Diagram>

## Server setup {#server-setup}

Most templates get A2A through the framework agent chat plugin. If you are mounting it yourself, call `mountA2A()` in a server plugin:

```ts
// server/plugins/a2a.ts
import { mountA2A } from "@agent-native/core/a2a";

export default defineNitroPlugin((nitro) => {
  mountA2A(nitro, {
    name: "Analytics Agent",
    description: "Runs analytics queries and returns chart data",
    skills: [
      {
        id: "run-query",
        name: "Run Query",
        description: "Execute a SQL query against the analytics database",
        tags: ["analytics", "sql"],
        examples: ["Show me signups by source this month"],
      },
    ],
    // Optional legacy external-peer bearer key. Prefer A2A_SECRET for
    // agent-native workspace calls and production deployments.
    apiKeyEnv: "A2A_API_KEY",
    streaming: true, // enable message/stream
  });
});
```

This mounts:

- `GET /.well-known/agent-card.json` — public discovery metadata.
- `POST /_agent-native/a2a` — primary agent-native JSON-RPC endpoint.
- `POST /_agent-native/a2a/_process-task` — internal async processor route, signed with `A2A_SECRET`.

The client also falls back to `/a2a` for external agents that expose the legacy/simple path. Production agent-native deployments should set `A2A_SECRET`; without it, hosted runtimes fail closed instead of accepting unauthenticated remote work.

## Agent card {#agent-card}

The agent card is auto-generated from your config and served at `/.well-known/agent-card.json`. Other agents fetch it to discover your agent's skills.

### Per-tenant skill filtering {#agent-card-filtering}

The card endpoint is public, so the framework redacts skills whose IDs reveal per-user or per-org integrations before serving it. Any skill whose id starts with `mcp__user_<emailhash>_…` or `mcp__org_<orgid>_…` is dropped from the published card. Operator-controlled stdio MCP tools (loaded from `mcp.config.json`) and template-defined skills stay visible. This prevents an unauthenticated caller from fingerprinting which tenants exist or which integrations they have connected. See `packages/core/src/a2a/server.ts`.

```json
{
  "name": "Analytics Agent",
  "description": "Runs analytics queries and returns chart data",
  "url": "https://analytics.example.com",
  "version": "1.0.0",
  "protocolVersion": "0.3",
  "capabilities": {
    "streaming": true,
    "pushNotifications": false,
    "stateTransitionHistory": true
  },
  "skills": [
    {
      "id": "run-query",
      "name": "Run Query",
      "description": "Execute a SQL query against the analytics database",
      "tags": ["analytics", "sql"],
      "examples": ["Show me signups by source this month"]
    }
  ],
  "securitySchemes": {
    "jwtBearer": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" },
    "apiKey": { "type": "http", "scheme": "bearer" }
  },
  "security": [{ "jwtBearer": [] }, { "apiKey": [] }]
}
```

_(Version may differ; fetch your app's live card at `/.well-known/agent-card.json` for the current `protocolVersion`.)_

When `A2A_SECRET` is set (the recommended path), the card advertises a
`jwtBearer` scheme as above. The `apiKey` scheme is only added when a legacy
`apiKeyEnv` is also configured, so a card with just `A2A_SECRET` set publishes
`jwtBearer` alone.

## JSON-RPC methods {#json-rpc-methods}

All methods are called via `POST /_agent-native/a2a` with JSON-RPC 2.0 format:

| Method           | Description                                                                                                           | Key params                    |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| `message/send`   | Send a message and wait for the completed task. Pass `async: true` to return immediately in `working` state and poll. | `message, contextId?, async?` |
| `message/stream` | Send a message, receive SSE task updates                                                                              | `message, contextId?`         |
| `tasks/get`      | Fetch a task by ID — used to poll an async task to completion                                                         | `id`                          |
| `tasks/cancel`   | Cancel a running task                                                                                                 | `id`                          |

<Endpoint id="doc-block-a6g596" title="Primary A2A endpoint" summary="All JSON-RPC methods are POSTed here. message/send shown." method="POST" path="/_agent-native/a2a" auth="JWT bearer signed with A2A_SECRET (or legacy apiKeyEnv static token)" params={[
  {
    "name": "Authorization",
    "in": "header",
    "type": "string",
    "required": false,
    "description": "Bearer token. Required in hosted production runtimes; optional in local dev."
  },
  {
    "name": "method",
    "in": "body",
    "type": "string",
    "required": true,
    "description": "One of message/send, message/stream, tasks/get, tasks/cancel."
  },
  {
    "name": "params.message",
    "in": "body",
    "type": "object",
    "required": false,
    "description": "{ role, parts[] } for message/send and message/stream."
  },
  {
    "name": "params.async",
    "in": "body",
    "type": "boolean",
    "required": false,
    "description": "Return immediately in working state and poll via tasks/get. Use on serverless hosts."
  },
  {
    "name": "params.id",
    "in": "body",
    "type": "string",
    "required": false,
    "description": "Task id for tasks/get and tasks/cancel."
  }
]} request={{
  "contentType": "application/json",
  "example": "{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"method\": \"message/send\",\n  \"params\": {\n    \"message\": {\n      \"role\": \"user\",\n      \"parts\": [{ \"type\": \"text\", \"text\": \"Show signups by source\" }]\n    },\n    \"async\": true\n  }\n}"
}} responses={[
  {
    "status": "200",
    "description": "JSON-RPC result containing the task. With async:true the task returns in working state.",
    "example": "{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"result\": { \"id\": \"task_123\", \"status\": { \"state\": \"working\" } }\n}"
  },
  {
    "status": "503",
    "description": "Hosted production runtime with no A2A_SECRET configured — fails closed instead of running unauthenticated."
  }
]}>

JSON-RPC 2.0 endpoint for `message/send`, `message/stream`, `tasks/get`, and `tasks/cancel`. Pass `async: true` to return immediately in `working` state and poll with `tasks/get`.

</Endpoint>

When `message/send` is called with `async: true`, the JSON-RPC handler enqueues the task and self-fires a POST to an internal `/_agent-native/a2a/_process-task` route so the handler runs in a fresh function execution with its own full timeout. This route is authenticated with an HMAC token bound to the task ID (5-minute lifetime, signed with `A2A_SECRET`). It is mounted before the `/_agent-native/a2a` JSON-RPC route so h3's prefix matching does not swallow it.

<Diagram id="doc-block-1nl4ck7" title="Async task lifecycle on serverless" summary="async:true returns working in milliseconds, then a fresh execution runs the agent loop while the caller polls." caption="A recurring sweeper re-claims any task left in flight if the function execution dies mid-run.">

```html
<div class="diagram-async">
  <div class="diagram-box" data-rough>
    message/send<br /><small class="diagram-muted">async: true</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-panel">
    <span class="diagram-pill">enqueue task</span
    ><span class="diagram-pill warn">return working</span
    ><small class="diagram-muted">~milliseconds</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&darr;</div>
  <div class="diagram-box" data-rough>
    self-fire POST /_agent-native/a2a/_process-task<br /><small
      class="diagram-muted"
      >HMAC token · fresh execution · full timeout</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-col">
    <div class="diagram-pill">tasks/get (poll)</div>
    <div class="diagram-arrow diagram-muted" aria-hidden="true">&#8635;</div>
    <div class="diagram-pill ok">completed</div>
  </div>
</div>
```

```css
.diagram-async {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
.diagram-async .diagram-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
.diagram-async .diagram-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
.diagram-async .diagram-arrow {
  font-size: 20px;
  line-height: 1;
}
```

</Diagram>

<Callout tone="warning">

**Serverless webhook and gateway timeouts.** Hosted environment gateways (such as Netlify, Vercel, or Cloudflare Pages) impose strict execution limits (often 10 to 30 seconds) on public-facing HTTP routes. Because agent loops can take significant time to run queries, fetch context, and execute tools, you **must use `async: true`** when calling A2A endpoints or handling external webhooks. This immediately returns a `working` status to the API gateway, keeping the connection open only for a few milliseconds, while the self-fired `/process-task` POST executes the agent loop in the background. Do not block the primary HTTP request waiting for the agent loop to finish.

</Callout>

Messages contain typed parts — text, structured data, and files can all travel in one message:

<AnnotatedCode
  id="doc-block-1p4yerq"
  title="A2A message with typed parts"
  language="json"
  code={
    '{\n  "role": "user",\n  "parts": [\n    { "type": "text", "text": "Show signups by source" },\n    { "type": "data", "data": { "dateRange": "last-30d" } },\n    {\n      "type": "file",\n      "file": { "name": "report.csv", "mimeType": "text/csv", "bytes": "..." }\n    }\n  ]\n}'
  }
  annotations={[
    {
      lines: "4",
      label: "text part",
      note: "Plain natural-language instruction the agent reads.",
    },
    {
      lines: "5",
      label: "data part",
      note: "Structured JSON arguments — e.g. a date range — passed alongside the prompt.",
    },
    {
      lines: "6-9",
      label: "file part",
      note: "Attach a file by name, `mimeType`, and base64 `bytes`.",
    },
  ]}
/>

## Client {#client}

The `A2AClient` class handles discovery, messaging, and streaming:

```ts
import { A2AClient } from "@agent-native/core/a2a";

const client = new A2AClient("https://analytics.example.com", "my-api-key");

// Discover agent capabilities
const card = await client.getAgentCard();
console.log(card.skills);

// Send a message and get a completed task
const task = await client.send({
  role: "user",
  parts: [{ type: "text", text: "Show signups by source this month" }],
});
console.log(task.status.state); // "completed"
// task.status.message is a Message object ({ role, parts }), not a string.
// Pull text out of its parts:
const reply = task.status.message?.parts
  .filter((p) => p.type === "text")
  .map((p) => p.text)
  .join("");
console.log(reply); // agent's response text

// Stream responses for long-running work
for await (const update of client.stream({
  role: "user",
  parts: [{ type: "text", text: "Generate a full quarterly report" }],
})) {
  console.log(update.status.state, update.status.message);
}
```

## Convenience helper {#convenience-helper}

For simple text-in/text-out calls, use `callAgent()`:

```ts
import { callAgent } from "@agent-native/core/a2a";

// One-shot: send text, get text back
const response = await callAgent(
  "https://analytics.example.com",
  "How many signups last week?",
  { apiKey: process.env.ANALYTICS_API_KEY },
);
console.log(response); // "There were 1,247 signups last week..."
```

## Programmatic workspace invoke {#programmatic-invoke}

For agent-native workspaces, prefer the `agentNative` helper when code or a
headless app needs to discover sibling apps and invoke them by id, name, or
URL. It uses the same discovery and A2A invocation primitives as the
`agent-native agents` and `agent-native invoke` CLI commands.

```ts
import { agentNative } from "@agent-native/core/agent-native";

const agents = await agentNative.listAgents();

const result = await agentNative.invoke(
  "analytics",
  "Summarize signups by source this month.",
  { userEmail: "steve@example.com" },
);

console.log(`Called ${result.target.name}: ${result.responseText}`);
```

Use this for composable mini-apps: Dispatch or an orchestrator app discovers
workspace siblings, then invokes the specialist app that owns the provider,
dataset, or workflow. In production agent-native apps, set `A2A_SECRET` in each
app environment and pass the caller identity (`userEmail`) so outbound calls are
signed as JWT bearer tokens. Use `apiKeyEnv` only for legacy external peers that
expect a static bearer token. Use local actions instead of invoking yourself.

## CLI: agent-native invoke {#cli-invoke}

`agent-native invoke` is a thin CLI wrapper over the same resolve-and-call primitive as `agentNative.invoke()`. Use it to trigger an app's agent from a shell script, a CI job, a cron entry, or any external system that can shell out — the CLI equivalent of posting to `/_agent-native/a2a` by hand.

```bash
agent-native invoke <app-or-url> "prompt" [options]
```

```bash
# Resolve "analytics" in the workspace registry and wait for the reply
agent-native invoke analytics "Summarize signups by source this month"

# Call a deployed app directly by URL, reading the bearer token from env
agent-native invoke https://analytics.example.com "How many signups last week?" \
  --api-key-env ANALYTICS_A2A_KEY

# Force one blocking message/send instead of the default async+poll
agent-native invoke analytics "Quick lookup" --sync

# Machine-readable output for scripting
agent-native invoke analytics "Summarize signups" --json
```

| Option                    | Description                                                               |
| ------------------------- | ------------------------------------------------------------------------- |
| `--agent <id\|name\|url>` | Target agent (alias for the positional `<app-or-url>`)                    |
| `--message <text>`        | Prompt (alias for the positional `"prompt"`)                              |
| `--api-key-env <name>`    | Read the bearer token from an environment variable                        |
| `--api-key <token>`       | Bearer token value, passed inline                                         |
| `--context-id <id>`       | A2A `contextId`, to continue an existing task's conversation              |
| `--self-app-id <id>`      | Current app id, used for self-call prevention                             |
| `--self-url <url>`        | Current app URL, used for self-call prevention                            |
| `--timeout-ms <n>`        | Async polling timeout                                                     |
| `--poll-interval-ms <n>`  | Async polling interval                                                    |
| `--sync`                  | Use one blocking `message/send` request instead of the async+poll default |
| `--no-hint`               | Send the prompt as-is, without the cross-app invocation hint              |
| `--json`                  | Print target metadata and response text as JSON                           |

Like `callAgent()`, the CLI defaults to async + poll (`invokeAgent` → `callAgent` under the hood), which is the safe choice against hosted/serverless targets — see [Serverless webhook and gateway timeouts](#json-rpc-methods) above. Pass `--sync` only when you know the target isn't behind a gateway with a short execution limit.

The target resolves the same way as `agentNative.invoke()`: by workspace app id, by app name, or by a full URL — so the same command works against a sibling app in a multi-app workspace or an arbitrary A2A peer elsewhere.

## Delegated agent activity {#delegated-agent-activity}

Agent Native peers may attach an optional `data` part with
`kind: "agent-native/agent-activity"` to task status messages. It carries a
bounded snapshot of the user-visible reasoning summaries, tool names and
completion states, elapsed time, and progressive response text. Calling apps
render these updates as a nested agent run and collapse the work history when
the delegated task finishes.

The activity part never includes tool inputs, tool results, credentials, or
hidden provider reasoning. Treat it as untrusted presentation data, never as
identity, authorization, approval, routing, or artifact validation. External
A2A peers can omit the extension; their ordinary status text and final response
still render in the same delegated-agent surface.

## Task lifecycle {#task-lifecycle}

Each message creates a task that moves through these states:

`submitted` → `working` → `completed` | `failed` | `canceled` | `input-required`

`input-required` is non-terminal: the handler is waiting for more information from the caller, and the task can move back to `working` once that input arrives.

| State            | Meaning                                        |
| ---------------- | ---------------------------------------------- |
| `submitted`      | Task created, queued for processing            |
| `working`        | Handler is processing the message              |
| `completed`      | Handler finished successfully                  |
| `failed`         | Handler threw an error                         |
| `canceled`       | Task was canceled via tasks/cancel             |
| `input-required` | Handler needs more information from the caller |

Tasks persist in the `a2a_tasks` SQL table and can be retrieved later via `tasks/get`.

## Security {#security}

Set `A2A_SECRET` on every production app that calls or receives A2A traffic. Agent-native callers sign JWT bearer tokens with this secret so receivers can verify the caller identity before the agent loop starts.

For external peers that still use a shared static token, set `apiKeyEnv` in your config to the name of an environment variable containing the expected bearer token:

```ts
// Config
mountA2A(app, {
  // ...
  apiKeyEnv: "A2A_API_KEY", // reads process.env.A2A_API_KEY
});

// Client calls with the matching key
const client = new A2AClient(url, process.env.A2A_API_KEY);
```

The agent card endpoint is always public (no auth) so other agents can discover capabilities. The `/_agent-native/a2a` JSON-RPC endpoint accepts JWT bearer tokens signed by `A2A_SECRET`, and also accepts the legacy `apiKeyEnv` token when configured. In local development, auth can be omitted; in hosted production runtimes, missing A2A auth returns 503 instead of running unauthenticated.

### Auth policy boundary {#auth-policy}

Bearer validation runs at the request boundary — in the JSON-RPC handler — before the agent loop ever sees the message. The shared helpers in `packages/core/src/a2a/auth-policy.ts` decide what the deployment requires:

- `isA2AProductionRuntime()` returns `true` on Netlify, AWS Lambda, Cloudflare Pages/Workers, Vercel, Render, Fly, and Cloud Run — even when `NODE_ENV` isn't `"production"`. Some serverless providers don't set `NODE_ENV` consistently, so the policy reads provider-specific flags too.
- `hasConfiguredA2ASecret()` returns `true` when `A2A_SECRET` is set.
- `shouldAdvertiseJwtA2AAuth()` is what the agent card uses to decide whether to publish a `jwtBearer` security scheme.

The production policy is strict: in any production runtime, the async `_process-task` route refuses to dispatch unless `A2A_SECRET` is configured (returns 503), and the JSON-RPC endpoint refuses unauthenticated calls. The dev fallback (warn once, allow) only fires when no production flag is set.

This boundary matters because the agent loop accepts free-form input from a remote caller. Putting the bearer check inside the loop, or relying on a tool to enforce it, would let prompt-injection or a buggy handler bypass auth. Keeping it at the HTTP boundary means a token failure short-circuits before any LLM call.

JWT verification (`verifyA2AToken` in `server.ts`) accepts tokens signed with either the global `A2A_SECRET` or an org-scoped secret looked up from SQL via the token's `org_domain` claim, and enforces the token's own `aud`/`iss` claims when present.

### Organization secret sync {#organization-secret-sync}

The **Organization → Cross-app authentication** setting manages the org-scoped secret used to authenticate delegation between separately deployed apps in the same organization. It is not a user sign-in or Cross-App SSO setting. The secret supplements a deployment's `A2A_SECRET`: an A2A JWT with an `org_domain` claim can be verified against the matching organization's secret.

An organization owner should give every participating app the same allowed email domain and the same org-scoped secret:

1. In the first app, generate or reveal the secret in **Organization → Cross-app authentication**.
2. In each additional app, choose **Paste secret from another app** and paste that value. This manual step is required for the first connection: an app that does not already have the secret cannot verify a remote sync request.
3. Once apps share the secret, use **Sync to apps** to push future updates to connected, discovered apps. The result reports any app that could not be updated.

When you **Regenerate** the secret, the settings UI immediately syncs it and signs the update with the previous secret so already connected apps can verify the change. If an app misses that sync, paste the new secret there manually, then sync again. Treat this secret like any other credential: do not put it in client-side code, logs, or a third-party client configuration.

## Continuations {#continuations}

When an agent calls a remote A2A peer that doesn't return immediately, the framework polls `tasks/get` until the task settles. This is wired through `A2AClient.sendAndWait`, which is the default mode used by the `callAgent()` helper.

```ts
// Default: async + poll (safe on serverless hosts)
const reply = await callAgent(url, "Generate the quarterly report", {
  userEmail: session.email,
});

// Single-shot blocking POST (avoid on Netlify/Vercel for slow handlers)
const reply2 = await callAgent(url, "Quick lookup", { async: false });
```

For inbound continuations triggered by a messaging integration (Slack, email), the framework persists the continuation in SQL and processes it out-of-band:

- A row is written to the `a2a_continuations` table when the integration handler hands off to a remote agent.
- A self-fired `POST /_agent-native/integrations/process-a2a-continuation` claims the row, calls `tasks/get` on the remote agent, and either delivers the reply to the integration adapter or reschedules.
- If the remote task is still working, the row is rescheduled and re-dispatched. The poll budget is **bounded by ~20 minutes of remote work** (`MAX_REMOTE_WORK_MS`) and **30 dispatch attempts** (`MAX_ATTEMPTS`); after either limit, the continuation is failed with a clear error and the user gets a "the agent didn't respond in time" reply.
- A recurring sweeper (`claimDueA2AContinuations`) re-claims any continuation rows that were left in flight when the previous function execution died. Even if the calling app crashes mid-poll, the next sweep tick resumes the work.

Defined in `packages/core/src/integrations/a2a-continuation-processor.ts`. The same retry job pattern is used for integration webhook tasks (`pending-tasks-retry-job.ts`), which is a distinct queue capped at 3 attempts — separate from the continuation-poll budget above.

## Workspace A2A {#workspace-a2a}

In a multi-app workspace deployed to a single Netlify site (see [multi-app workspace](/docs/multi-app-workspace)), every app under `apps/<id>/` is auto-registered as an A2A peer:

- A shared `A2A_SECRET` is mounted into every app's environment at build time.
- Cross-app calls are same-origin — `https://workspace.example.com/apps/analytics` calls `https://workspace.example.com/apps/mail` — so there is no DNS, CORS, or per-pair JWT setup.
- Outbound calls signed with the shared secret carry the caller's email as `sub` and (when present) the org domain. The receiver's JWT verifier accepts either the shared secret or the org-scoped secret from SQL, in that order.
- Agent discovery walks the workspace registry rather than relying on the operator to wire each peer by hand. See `discoverAgents` in `packages/core/src/server/agent-discovery.ts` and the org refresh path in `packages/core/src/org/handlers.ts`.

External A2A — calls to agents outside your workspace — still uses the bearer-token model (`apiKeyEnv` + `A2AClient(url, apiKey)`). Workspace A2A is layered on top; nothing about external peers changes.

## Serverless gotchas {#serverless}

**Never rely on a fire-and-forget `Promise` outliving the response.** Serverless functions (Netlify, Vercel, AWS Lambda, Cloud Run) freeze the moment the response body is flushed — sometimes before the TCP handshake of an unawaited `fetch(...)` even completes. Patterns that work locally on Node will silently drop work in production.

The framework's pattern, used by both A2A async dispatch and the [integration webhook queue](/docs/messaging), is:

1. Accept the request, persist what needs to happen to SQL, return 200 immediately.
2. Self-fire a `POST` to a separate framework route (`/_agent-native/a2a/_process-task` or `/_agent-native/integrations/process-task`) so the actual work runs in a **fresh function execution** with its own full timeout.
3. Authenticate the self-fire with an HMAC token bound to the row id, signed with `A2A_SECRET`.
4. A recurring retry job sweeps any rows that were claimed but not finished, so a crashed function doesn't strand the work.

When you write your own A2A handler or integration adapter, follow the same shape. Don't attach work to a detached promise after `return`. If you must self-fire from a serverless handler, start the fetch before returning and give it a tiny head start (the framework uses a short timeout) so Lambda-style runtimes do not freeze before the outbound request leaves the process. The `integration-webhooks` skill is the canonical reference.

## Agent mentions {#agent-mentions}

You can `@`-mention agents directly in the chat composer. Connected agents use A2A: when you mention a connected agent, the server makes an A2A call to that agent and weaves the response into your conversation context.

Custom workspace agents are different: they run locally inside the current app/runtime rather than over A2A.

See [Agent Mentions](/docs/agent-mentions) for details on how mentions work, how to add agents, and how to create custom mention providers.

## Messaging integrations {#messaging-integrations}

Agents can also be reached from external messaging platforms like Slack, email, Telegram, and WhatsApp. Users send messages on those platforms and the agent responds in the same thread, using the same tools and actions as the web chat.

See [Messaging](/docs/messaging) for setup details on each platform.

## Example: cross-agent query {#example}

A mail agent needs analytics data. The analytics agent exposes a "run-query" skill via A2A:

```ts
// In the mail agent's actions/get-analytics.ts
import { defineAction } from "@agent-native/core/action";
import { callAgent } from "@agent-native/core/a2a";
import { z } from "zod";

export default defineAction({
  description: "Ask the analytics agent a question.",
  schema: z.object({ question: z.string() }),
  async run({ question }) {
    const response = await callAgent(
      "https://analytics.example.com",
      question,
      { apiKey: process.env.ANALYTICS_API_KEY },
    );
    return { answer: response };
  },
});
```

The analytics agent receives the message, runs the query via its handler, and returns the result. The mail action gets the text response back. No shared database, no direct API calls — just agent-to-agent communication.

## What's next {#whats-next}

- [**MCP Server**](/docs/mcp-protocol#mcp-vs-a2a) — the sibling protocol for external tools calling your app directly
- [**Dispatch**](/docs/dispatch) — the app that routes inbound messages to the right A2A peer
- [**Messaging**](/docs/messaging) — reaching agents from Slack, email, Telegram, and WhatsApp
- [**Agent Mentions**](/docs/agent-mentions) — `@`-mentioning a connected agent in chat, which calls A2A under the hood
- [**Multi-App Workspaces**](/docs/multi-app-workspace) — same-origin workspace A2A with auto-signed credentials
