---
title: "A2A agents"
description: "Every agent and flow is an A2A agent with a signed Agent Card, and its tasks are jobs."
canonical_url: "https://docs.oz0.ai/build/apps/a2a"
---
# A2A agents

> Every agent and flow is an A2A agent with a signed Agent Card, and its tasks are jobs.

For: application developers.

Call an agent or flow through A2A v1.0 when integrating with another team's agent or an A2A client. The client fetches and verifies the signed Agent Card, then sends messages to the agent. Each A2A task is an Orchestrator Zero job.

<table>
<thead>
  <tr>
    <th>
      Route
    </th>
    
    <th>
      What it is
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        GET /a2a/&lt;tenant>/&lt;agent>/.well-known/agent-card.json
      </code>
    </td>
    
    <td>
      The Agent Card, generated from the agent's contract and signed
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /a2a/&lt;tenant>/&lt;agent>
      </code>
    </td>
    
    <td>
      Its JSON-RPC interface: <code>
        SendMessage
      </code>
      
      , <code>
        GetTask
      </code>
      
      , <code>
        CancelTask
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        GET /a2a/&lt;tenant>/jwks.json
      </code>
    </td>
    
    <td>
      The tenant's public key, which signatures name in <code>
        jku
      </code>
    </td>
  </tr>
</tbody>
</table>

The card and the interface take a tenant [API key](https://docs.oz0.ai/build/apps/runtime-api/authentication#authentication) as `Authorization: Bearer`, like the runtime API; the key set is public. Requests carry `A2A-Version: 1.0`.

## The card

```json [agent-card.json]
{
  "name": "acme-reviewer",
  "description": "Reviews a pull request.",
  "supportedInterfaces": [{"url": "https://edge.example.com:7443/a2a/acme/acme-reviewer", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}],
  "provider": {"organization": "Orchestrator Zero, tenant acme", "url": "https://edge.example.com:7443"},
  "version": "9f2c41d07a6b3e18",
  "capabilities": {"streaming": false, "pushNotifications": false, "extendedAgentCard": false},
  "securitySchemes": {"apiKey": {"httpAuthSecurityScheme": {"scheme": "Bearer", "bearerFormat": "oz0_<id>_<secret>"}}},
  "defaultInputModes": ["application/json"], "defaultOutputModes": ["application/json"],
  "skills": [{"id": "acme-reviewer", "name": "acme-reviewer", "description": "Reviews a pull request.", "tags": ["github-tools", "agent"]}],
  "signatures": [{"protected": "eyJhbGciOiJFUzI1NiIs...", "signature": "..."}]
}
```

An agent that takes text has the mode `text/plain`; one with an input or output schema, `application/json`. `version` is the tenant's catalog hash, so it changes when a plugin install changes the agents, and the card's `ETag` with it.

The signature is a JWS (ES256) over the card's canonical form (RFC 8785, without `signatures` and without empty values), as A2A v1.0 specifies. Each tenant has its own P-256 key, derived from the cluster's master key, so every edge signs alike. Its protected header names the key (`kid`) and where it is (`jku`). With the Python SDK:

```python [verify.py]
import httpx, jwt
from a2a.client import A2ACardResolver
from a2a.utils.signing import create_signature_verifier


def key(kid, jku):
    keys = httpx.get(jku, verify="ca.crt").json()["keys"]
    return next(jwt.PyJWK(k) for k in keys if k["kid"] == kid)


card = await A2ACardResolver(http, "https://edge.example.com:7443/a2a/acme/acme-reviewer").get_agent_card(
    signature_verifier=create_signature_verifier(key, ["ES256"])
)
```

Pin the tenant's key once you trust it, rather than trusting whatever key a `jku` points to.

## Contexts

A task's `contextId` is its job's [conversation](https://docs.oz0.ai/agents/conversations), or the job's own ID until it has one. Send the next message with that `contextId` and no `taskId`, and it starts the conversation's next turn as a new task in the same context: the agent gets the earlier turns. An agent that takes no part in conversations (one whose input has no `message`), or a key without the content permission, gets a new task in a context of its own instead, so a client that always sends back the `contextId` it got keeps working.

A `contextId` the server never gave, or another agent's, is refused with `-32602`, and a message whose `contextId` differs from its task's is refused too ([A2A v1.0, section 3.4.1](https://a2a-protocol.org/latest/specification/)).

A context is its maker's: only the key, operator or person that started it continues it, or a person its [chat](https://docs.oz0.ai/agents/conversations) is shared with. Anyone else who sends its `contextId`, another key of the same tenant included, gets the same `-32602` as for a context the server never gave, so a `contextId` is no way into someone else's history (A2ABreak's cross-client context injection, [arXiv 2609.10871](https://arxiv.org/abs/2609.10871)).

The card's URLs (its interface, `provider.url` and the signature's `jku`) are the edge API's advertised address: the server's `--advertise` host and its edge API port, the `edge_api` address in the operator context (`context.json`) and in `GET /api/info`. They never come from the request: whatever `Host` header a caller sends, a signed card names the cluster's own endpoint, so nobody can obtain a validly signed card that points at their own host. Behind a load balancer or a proxy, set `--advertise` to the name your A2A clients use.

## Messages and tasks

`SendMessage` with a new message starts a job. Its input is the text of the message's parts for an agent that takes text, or for one with an input schema, its first data part (or a text part that holds JSON). It waits until the job ends or waits for a person, unless `configuration.returnImmediately` is set, and answers with the task:

<table>
<thead>
  <tr>
    <th>
      Task state
    </th>
    
    <th>
      The job
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        TASK_STATE_WORKING
      </code>
    </td>
    
    <td>
      runs
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        TASK_STATE_INPUT_REQUIRED
      </code>
    </td>
    
    <td>
      waits for an approval or a question; the status message says what, with a data part that lists each one
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        TASK_STATE_COMPLETED
      </code>
    </td>
    
    <td>
      ended; the artifact <code>
        output
      </code>
      
       holds the answer, as text or as data
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        TASK_STATE_FAILED
      </code>
      
      , <code>
        TASK_STATE_CANCELED
      </code>
    </td>
    
    <td>
      failed, timed out or was canceled; the status message says why, and when a budget, quota or tree limit ended the job, has a data part <code>
        {"error": {"type", "message", "oz0"}}
      </code>
      
       with its numbers (<a href="https://docs.oz0.ai/reference/spend-refusals">
        Spend refusals
      </a>
      
      )
    </td>
  </tr>
</tbody>
</table>

To answer, send another message with the task's `taskId`:

- A question: a text part with the answer.
- A tool approval: a data part `{"approve": true}` or `{"approve": false, "comment": "..."}`. Text never approves a call. A tenant API key decides an approval only when the approval's deciders or a notification rule name it ([keys that decide](https://docs.oz0.ai/agents/approvals/configure#keys-that-decide)); it answers, in its messages to the task, the questions of the tasks it started.
- With several waiting, name one: `{"approval": "a-7f2c", "answer": "blue"}`.

Messages from the job are `ROLE_AGENT`. The task, its status messages and its artifact carry the tenant's [AI label](https://docs.oz0.ai/govern/ai-labels) in `metadata["ai.oz0/ai-label"]` (none when an admin turned it off): show it to the people who read the answer.

`GetTask` reads the task, and `CancelTask` cancels its job. A task of another agent is not found. Streaming, push notifications, `ListTasks` and the extended card are not offered, and the card says so.

## Hardening

- The artifact's hash. A completed task's artifact carries `metadata["ai.oz0/sha256"]`, `sha256:<hex>` of its `parts` in RFC 8785's canonical form: recompute it from the parts you received to check nothing changed on the way.
- One message to a task at a time. A message that answers a task while another message to it is being handled is refused with `-32004`; read the task with `GetTask` and answer what it still asks.
- Waiting ends at the deadline. Each approval or question in an `TASK_STATE_INPUT_REQUIRED` status names its `deadline`; after it, the job denies it and the task is no longer waiting for it.

### Delegation loops

A message may name the job it comes from in `metadata["ai.oz0/origin-job"]`. One whose agent is already on that job's chain (its job tree, and the jobs earlier messages came from, at most eight hops) is refused with `-32004`, and so is a chain deeper than eight. Only messages that carry the marker are checked. The platform's own runtime sends it on every call it makes to one of this cluster's own addresses, an [outside A2A agent](https://docs.oz0.ai/build/apps/remote-agents/a2a#calls-back-into-this-cluster)'s message or a remote MCP tool's call, and never to anyone else.

### Errors

A refusal (a permission, an argument, a spent quota) says what it refuses and why. An error the edge met rather than decided, such as its database or Temporal being away a moment, is answered with fixed words ("the edge cannot answer now; ask again shortly") and its own text stays in the edge's log; MCP and AG-UI clients, and callers of the runtime API over Connect, are answered the same way.

Where the edge knows what you can do instead, such as starting without a job ID of your own while it cannot reach management, it says that in words written for you.

- Revocation. A blocking `SendMessage` ends at once when its key is revoked or its caller loses the tenant, with a `permission_denied` error that names the job. The revocation stops that job too when it ran on that access, as it stops every job of a revoked key or person, and its [receipt](https://docs.oz0.ai/govern/kill-switches#receipts) lists them.
- No credentials asked or passed on. The server never answers `TASK_STATE_AUTH_REQUIRED`: a tool that needs a credential uses the tenant's own [connections](https://docs.oz0.ai/admin/connections), never the caller's.
- A scoped key ([`oz0 apikey create --scope agent:<name>`](https://docs.oz0.ai/build/apps/runtime-api/authentication#keys-scoped-to-agents-and-flows)) reaches only the agents its scopes name: another agent's card is not found, and so are its tasks and contexts, with the answer an ID that does not exist gets.

Push notifications and streaming stay off until each can be offered safely: push needs webhook URLs checked against the tenant's egress rules, verified with the receiver and signed; streaming needs each event checked against what the caller may see, and the stream ended when that changes.

With the JavaScript SDK (`@a2a-js/sdk` 1.3.0), verify the card's JSON as you fetched it with `verifyAgentCardSignature`: its resolver turns the card into objects whose canonical form loses `securitySchemes`, so verifying through `client.getAgentCard` fails. Which clients were run, and what each did: the [interop matrix](https://docs.oz0.ai/build/apps/interop).

## Calling outside agents

This page is about the A2A server every agent is. The other direction, an outside agent that speaks A2A used as an agent of your tenant, runs through the verified plugin `a2a-remote`: see [Outside A2A agents](https://docs.oz0.ai/build/apps/remote-agents/a2a#outside-a2a-agents).

Its client holds to the same rules from the other side: it binds an outside agent to its card's origin, checks a card's signature where there is one, polls instead of streaming and gives no push address, sends one message to a task at a time, answers a question only after the job's approval or a person's answer and cancels the task at the approval's deadline, refuses to pass on credentials, checks artifacts' hashes, and keeps a task's context to its own job.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
