> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# A2A federation

> Task remote A2A peer agents and accept inbound tasks from peers — implements the A2A v0.3.0 protocol.

# A2A federation

Orbit agents speak the [A2A (Agent-to-Agent) protocol v0.3.0](https://a2a-protocol.org/), so any A2A-conformant peer can discover and task an Orbit agent, and any of your Orbit agents can task remote A2A peers (Google AgentSpace, Azure AI Foundry, Bedrock AgentCore, ServiceNow, SAP, Salesforce, Workday, IBM, custom builds — anything that implements the spec).

This is the only standardised cross-vendor agent-to-agent protocol with broad ecosystem adoption (150+ orgs in production by April 2026). On Orbit it is GA and per-tenant.

## What you get

* **Opt-in discovery** — each agent can publish an [A2A AgentCard](https://a2a-protocol.org/latest/specification/#agent-card) at a stable URL once you turn discovery on (it ships **off** by default — see [Discovery modes](#discovery-modes)). Skills are derived from the agent's tool list.
* **JSON-RPC + REST** — both transports are mounted, identically authenticated.
* **HMAC signing** — Stripe / Svix-style `X-A2A-Signature: t=…,v1=…` with a ±60s replay window.
* **Per-tenant ledger** — every inbound and outbound task is persisted with full input / output / status / direction so the dashboard can render task history and you have an audit trail.
* **Peer registry** — known peers are cached per agent. The platform fetches the peer's AgentCard on add and stores it for discovery.

## Discovery modes

A new agent does **not** serve a public AgentCard. Discovery is an explicit operator opt-in, controlled by the agent's `a2a_discovery_mode` field. It has three values:

| `a2a_discovery_mode`   | Public `/.well-known/agent-card.json` | Authenticated dashboard `/a2a/card` |
| ---------------------- | ------------------------------------- | ----------------------------------- |
| `disabled` *(default)* | `404`                                 | `422 A2A_DISCOVERY_DISABLED`        |
| `tenant`               | `404`                                 | Serves the card                     |
| `public`               | Serves the card                       | Serves the card                     |

Set the mode on the agent before pointing a peer at the public URL:

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/agents/agent_abc \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "a2a_discovery_mode": "public" }'
```

In the dashboard the same toggle lives under **Agents → {agent} → A2A**. Until you switch an agent to `public`, the public `/.well-known/agent-card.json` route returns `404` (the response shape never distinguishes "discovery off" from "agent doesn't exist", to avoid leaking which agents exist). `tenant` mode keeps the card private — it serves only the authenticated dashboard route below, never the public one.

## Public surface (for peers + spec-compliant clients)

These endpoints DO NOT use the dashboard session. Tenants are resolved from a `?tenant=<schemaName>` query param baked into the AgentCard URL.

| Method | Path                                                                   | Spec role                                                                                       |
| ------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `GET`  | `/api/v1/agents/{agentId}/.well-known/agent-card.json?tenant=<schema>` | Canonical AgentCard (A2A v0.3 §4.4).                                                            |
| `GET`  | `/api/v1/agents/{agentId}/.well-known/agent.json?tenant=<schema>`      | Legacy v0.2 alias — same payload.                                                               |
| `POST` | `/api/v1/agents/{agentId}/a2a`                                         | JSON-RPC 2.0 entry point. Methods: `agent/discover`, `tasks/send`, `tasks/get`, `tasks/cancel`. |
| `POST` | `/api/v1/agents/{agentId}/a2a/tasks`                                   | REST shorthand for `tasks/send`.                                                                |
| `GET`  | `/api/v1/agents/{agentId}/a2a/tasks/{taskId}`                          | REST shorthand for `tasks/get`.                                                                 |

Get the per-agent canonical AgentCard URL from **Agents → {agent} → A2A → Copy share URL** in the dashboard. The URL is the only thing a peer needs.

## HMAC signing

All public POST endpoints (`/a2a` and `/a2a/tasks`) require a signed request:

```
X-A2A-Signature: t=<unix_seconds>,v1=<hex_sha256>
```

The signed payload is `${ts}.${rawBody}`. Replay window is ±60 s. The platform uses constant-time comparison after explicit length-equality.

The HMAC key is the per-org A2A secret, configurable via `DEVOTEL_A2A_HMAC_SECRET`. It falls back to `DEVOTEL_API_SECRET_KEY` for fresh deployments.

The A2A spec (§4.5) lists OAuth2 / OIDC / API Key / HTTP-Auth / mTLS as supported security schemes; Orbit advertises HMAC as an `APIKeySecurityScheme` (header location, name `X-A2A-Signature`) — a spec-compatible escape hatch for peers without OAuth tooling.

### Signing example (Node.js)

```typescript theme={null}
import { createHmac } from "node:crypto";

const ts = Math.floor(Date.now() / 1000).toString();
const rawBody = JSON.stringify({ jsonrpc: "2.0", method: "tasks/send", id: 1, params: { /* ... */ } });
const sig = createHmac("sha256", process.env.DEVOTEL_A2A_HMAC_SECRET!)
  .update(`${ts}.${rawBody}`)
  .digest("hex");

await fetch("https://api.orbit.devotel.io/api/v1/agents/agent_abc/a2a?tenant=tenant_demo", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-A2A-Signature": `t=${ts},v1=${sig}`,
  },
  body: rawBody,
});
```

## Discover a peer's AgentCard

This returns a card only when the target agent's [`a2a_discovery_mode`](#discovery-modes) is `public`; otherwise it returns `404`.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/agents/agent_abc/.well-known/agent-card.json?tenant=tenant_demo"
```

The response follows the A2A spec. Skills are derived from the agent's configured tools; an agent with no tools surfaces a single default skill keyed on `agent.type`.

## Task an Orbit agent (inbound)

JSON-RPC:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents/agent_abc/a2a?tenant=tenant_demo \
  -H "Content-Type: application/json" \
  -H "X-A2A-Signature: t=1746783262,v1=..." \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tasks/send",
    "params": {
      "skill": "lookup_order",
      "message": {
        "role": "user",
        "parts": [{ "kind": "text", "text": "What is the status of order ORD-12345?" }]
      }
    }
  }'
```

REST shorthand:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents/agent_abc/a2a/tasks?tenant=tenant_demo \
  -H "Content-Type: application/json" \
  -H "X-A2A-Signature: t=1746783262,v1=..." \
  -d '{
    "skill": "lookup_order",
    "message": {
      "role": "user",
      "parts": [{ "kind": "text", "text": "What is the status of order ORD-12345?" }]
    }
  }'
```

Both return a Task envelope (`id`, `status`, `output`, etc.). Poll `GET /api/v1/agents/{agentId}/a2a/tasks/{taskId}` (the public REST shorthand for `tasks/get`, listed above) to follow progress. Like the POST endpoints, it requires an HMAC signature — sign over `${ts}.` (the empty body).

## Authenticated dashboard surface

These require a Clerk session and operate on the calling user's tenant:

| Method   | Path                                          | Purpose                                                     |
| -------- | --------------------------------------------- | ----------------------------------------------------------- |
| `GET`    | `/api/v1/agents/{agentId}/a2a/card`           | Full AgentCard + the operator-shareable public URL.         |
| `GET`    | `/api/v1/agents/{agentId}/a2a/tasks`          | Inbound + outbound task history.                            |
| `POST`   | `/api/v1/agents/{agentId}/a2a/tasks/outbound` | Operator-initiated outbound task to a known peer.           |
| `GET`    | `/api/v1/agents/{agentId}/a2a/peers`          | List known peers for this agent.                            |
| `POST`   | `/api/v1/agents/{agentId}/a2a/peers`          | Register a peer. The platform fetches its AgentCard on add. |
| `DELETE` | `/api/v1/agents/{agentId}/a2a/peers/{peerId}` | Remove a peer.                                              |

## Add a peer

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents/agent_abc/a2a/peers \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "peer_url": "https://partner.example.com/api/v1/agents/billing-bot/.well-known/agent-card.json"
  }'
```

The platform fetches the AgentCard, stores it on the peer row, and surfaces the peer's skill catalog in the dashboard. The peer URL is keyed unique per `(agent_id, peer_url)` — re-adding upserts.

## Delegate a task to a peer (outbound)

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents/agent_abc/a2a/tasks/outbound \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "peer_url": "https://partner.example.com/api/v1/agents/billing-bot/.well-known/agent-card.json",
    "skill": "billing_status",
    "message": {
      "role": "user",
      "parts": [{ "kind": "text", "text": "Get the current invoice for tenant 42." }]
    }
  }'
```

Persists an outbound task envelope, signs the request, and forwards. The response carries the peer's task id; the dashboard surfaces both sides of the round-trip in the agent's A2A tab.

## Tenant-scoping rules

* The `?tenant=<schemaName>` query param value MUST match `^tenant_[a-z0-9_]{8,80}$`, matched **case-insensitively** (the regex carries the `/i` flag, so a mixed-case value such as `Tenant_Demo` is also accepted). If you validate the value client-side, use a case-insensitive test — e.g. `/^tenant_[a-z0-9_]{8,80}$/i` — so you don't reject a value the server accepts. Any other value is rejected — guards against `?tenant=public` or admin-schema escalation attempts.
* Tasks are stored on the tenant's `agent_a2a_tasks` table; peers on `agent_a2a_peers`. Both are isolated per-tenant.

## Observability

Inbound + outbound tasks land in:

* **Dashboard** — Agents → {agent} → A2A. Direction badges (`inbound` / `outbound`), status pills, latency. Task envelopes are stored on `agent_a2a_tasks` and surfaced here.
* **Audit log** — peer lifecycle is recorded as `agent.a2a.peer_added` and `agent.a2a.peer_removed`. (Task delivery is not audited; inspect it via the dashboard A2A tab.)

## Error codes

| Code                        | HTTP | Cause                                                                                                                                                                                                                                                                                       |
| --------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHORIZED`              | 401  | A2A HMAC signature is missing, malformed, expired (replay window), or mismatched. The response message is a fixed operator-safe string — the exact discriminant is never leaked to the peer. On the JSON-RPC endpoint this is returned as a JSON-RPC `INVALID_REQUEST` error with HTTP 401. |
| `VALIDATION_ERROR`          | 400  | The `?tenant=` query param is missing, or its value does not match `^tenant_[a-z0-9_]{8,80}$` (matched case-insensitively — the `/i` flag).                                                                                                                                                 |
| `INVALID_PARAMS` (JSON-RPC) | 400  | A `tasks/send` call omits the required `skill` param (`Missing param: skill`), or carries a missing/invalid `message`. Returned as a JSON-RPC error object, not an HTTP error envelope.                                                                                                     |
| `A2A_PEER_ERROR`            | 502  | Outbound delegation failed, or the peer's AgentCard endpoint is unreachable.                                                                                                                                                                                                                |

## Spec compliance

Orbit implements A2A v0.3.0 — see the [protocol spec](https://a2a-protocol.org/latest/specification/) and the [reference proto](https://github.com/a2aproject/A2A/blob/main/specification/a2a.proto). Task envelopes, AgentCard structure, JSON-RPC method names, and the part-oneof for messages all match the spec.

## AP2 mandates across networks

A2A tasks a peer agent; [AP2](https://ap2-protocol.org/) is the adjacent protocol a peer uses to prove it was authorized to transact on someone's behalf. When you're federating with agents on another AP2 network — Mastercard, PayPal, or Google-side agents — a mandate can now carry an Ed25519-signed, W3C Verifiable-Credential proof instead of Orbit's own platform-only signature, and Orbit's mandate verifier resolves the issuer's public key from a JWKS/DID directory (Orbit's own by default, or a peer network's when you point it at theirs) before trusting the mandate's claims. That's what lets an Orbit agent accept a mandate issued by another network's agent, and vice versa, without the two networks sharing a secret.

## See also

* [Agents API](/api-reference/agents) — agent CRUD, tools, knowledge bases.
* [Webhooks → Security](/webhooks/security) — same HMAC-with-replay-window scheme as A2A.
* [Agent handoff targets](/agents/handoff-targets) — internal handoff between agents in the same tenant.
