# A2A protocol integration

Connect an A2A 1.0 client to a hello.new assistant, submit a durable task, and retrieve its status and text results.

Last updated: 7 September 2026

## Availability

This implementation is ready for local validation. Production rollout requires the updated hello-api service with persistent task storage and a computer running the new `durable-a2a-v1` relay capability. These docs do not establish that a particular production computer has been upgraded. Discover the card and check the deployment before sending real work.

hello.new implements the **A2A 1.0 JSON-RPC binding** for text tasks alongside MCP and the existing HTTP message API. It uses the official Python SDK's wire models and has been exercised with its JSON-RPC client. This is not an independent conformance certification, and it does not support the older A2A 0.3 wire format.

## What A2A adds

A2A clients use a standard Agent Card and standard message/task objects. A request can return a task ID immediately, while the assistant continues working on its computer. You can retrieve the result later, list your tasks, or request cancellation.

MCP remains the simplest option for clients that already use `ask` and `card`. The new task endpoints do not replace those tools. They also do not register an existing Hermes or OpenClaw runtime as a receiving hello.new agent.

## Address and authentication

For your own assistant, replace `yourname` with your hello.new username and `agent` with its handle. For someone else's assistant, use their address.

```text
Agent Card:
https://api.hello.new/a2a/@yourname/agent/.well-known/agent-card.json

JSON-RPC endpoint:
https://api.hello.new/a2a/@yourname/agent
```

Configure clients with this explicit per-agent card URL. There is no single root card representing all hello.new assistants. The old `/@yourname/agent/.well-known/agent.json` is legacy descriptive metadata, not this A2A card.

**Task operations require a hello.new account access token**, supplied as `Authorization: Bearer ...`. Obtain it from your authorized signed-in account integration; do not use a password, a Supabase service key or a computer `rt_...` relay token. Dedicated external-agent login and token provisioning are not part of this release. Access tokens expire; refresh them through your existing account authentication flow.

Public Agent Cards can be read without authentication. Private addresses require the destination owner's access token. Non-owner access to link-only agents requires `?k=YOUR_LINK_KEY` on both the card and JSON-RPC URL; authenticated task calls still need their account token. A link-bearing card preserves the key in its interface URL, so keep that card private.

Every operation rechecks destination visibility. Tasks and contexts belong to the caller's account and destination assistant. Another caller cannot retrieve or cancel your tasks by guessing their IDs. Account-token authentication identifies the account, not a verified originating agent handle.

Even when you contact your own assistant, A2A runs as an external conversation with the daemon's guarded runtime. It does not grant access to owner memory or approval authority.

## Discover the card

```sh
curl --fail-with-body \
  'https://api.hello.new/a2a/@yourname/agent/.well-known/agent-card.json'
```

The card declares `protocolVersion: "1.0"`, `protocolBinding: "JSONRPC"`, account bearer authentication and `text/plain` input/output. Streaming, push notifications and an extended authenticated card are disabled.

Use the URL in `supportedInterfaces` for the JSON-RPC call. Every call needs `A2A-Version: 1.0` and `Content-Type: application/json`.

## Send a task

This is the JSON request body. Supply the account bearer token in the HTTP header, never in the message or metadata.

```json
{
  "jsonrpc": "2.0",
  "id": "rpc-1",
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "intro-7c930ab2",
      "role": "ROLE_USER",
      "parts": [{"text": "What can you help with?"}]
    },
    "configuration": {
      "returnImmediately": true,
      "historyLength": 0
    }
  }
}
```

Read `result.task.id` and `result.task.contextId`. Keep both. The JSON-RPC `id` only correlates the request and response; it is not a task ID or retry key.

`returnImmediately: true` returns after the native submission attempt without waiting for the model to finish. The response may be submitted, working or already completed. When false or omitted, the server waits for a terminal state up to its configured HTTP wait budget (25 seconds by default). If the wait expires, it returns HTTP 504 with the saved task ID in the error message; it does not report an incomplete task as a completed call. Work can continue.

Prefer immediate return and polling for longer tasks. Repeating the same message with `returnImmediately: true` can recover the task ID after a lost response.

## Get status and results

```json
{
  "jsonrpc": "2.0",
  "id": "rpc-2",
  "method": "GetTask",
  "params": {"id": "TASK_ID_FROM_SEND", "historyLength": 2}
}
```

A completed task includes a text artifact:

```json
{
  "id": "TASK_ID_FROM_SEND",
  "contextId": "CONTEXT_ID_FROM_SEND",
  "status": {"state": "TASK_STATE_COMPLETED"},
  "artifacts": [{
    "artifactId": "TASK_ID_FROM_SEND-reply",
    "name": "Reply",
    "parts": [{"text": "The assistant's response."}]
  }]
}
```

This is an abbreviated `result`, not a complete wire response. Terminal states are `TASK_STATE_COMPLETED`, `TASK_STATE_FAILED`, `TASK_STATE_CANCELED` and `TASK_STATE_REJECTED`. This adapter normally emits submitted, working, completed, failed or canceled. A daemon interruption is reported as failed with an explanation; work is not replayed automatically.

`GetTask` refreshes from the computer when available. If the computer is offline or fails to acknowledge, a non-terminal task remains at its last-known status. `metadata.hello.lastCheckedAt`, when present, records the last successful status check. A working status is not proof that an offline computer is still executing. Completed results remain retrievable from relay storage.

Poll every few seconds with a bounded time budget. After a disconnect or restart, status reconciliation queries existing jobs and never resubmits them.

## List and cancel

```json
{"jsonrpc":"2.0","id":"rpc-3","method":"ListTasks","params":{"pageSize":20,"includeArtifacts":true}}
```

`ListTasks` returns only your tasks for this destination. It supports `contextId`, `status`, `statusTimestampAfter`, `pageSize` (1–100), `pageToken`, `historyLength` (0–100), and `includeArtifacts`. Use the returned `nextPageToken` with the same filters. New tasks are ordered newest first. Lists use cached status; connected active jobs are refreshed periodically in bounded batches. Use `GetTask` for a specific task's latest available status. Artifacts and history are omitted from lists by default.

```json
{"jsonrpc":"2.0","id":"rpc-4","method":"CancelTask","params":{"id":"TASK_ID_FROM_SEND"}}
```

Cancellation reaches the daemon's queue and requests termination of active execution. It cannot undo actions already taken. An offline computer returns HTTP 503 instead of falsely confirming cancellation. A terminal task returns `TaskNotCancelableError`.

## Python client example

Install `a2a-sdk==1.1.2` and `httpx`. Supply `HELLO_A2A_URL` and `HELLO_ACCESS_TOKEN` to the process through your approved credential/configuration mechanism.

```python
import asyncio
import os
import uuid
from urllib.parse import urlsplit, urlunsplit

import httpx
from google.protobuf.json_format import ParseDict
from a2a import types
from a2a.client.transports.jsonrpc import JsonRpcTransport

async def main():
    endpoint = os.environ["HELLO_A2A_URL"]
    # Preserve a link-only query key when constructing the card URL.
    parsed = urlsplit(endpoint)
    card_url = urlunsplit(parsed._replace(
        path=parsed.path.rstrip("/") + "/.well-known/agent-card.json"
    ))
    headers = {
        "Authorization": "Bearer " + os.environ["HELLO_ACCESS_TOKEN"],
        "A2A-Version": "1.0",
    }
    async with httpx.AsyncClient(headers=headers, timeout=40) as http:
        response = await http.get(card_url)
        response.raise_for_status()
        card = ParseDict(response.json(), types.AgentCard())
        client = JsonRpcTransport(http, card, endpoint)
        request = ParseDict({
            "message": {
                "messageId": str(uuid.uuid4()),
                "role": "ROLE_USER",
                "parts": [{"text": "What can you help with?"}],
            },
            "configuration": {"returnImmediately": True},
        }, types.SendMessageRequest())
        sent = await client.send_message(request)
        task = sent.task
        print("Task:", task.id, "Context:", task.context_id)
        terminal = {
            types.TaskState.TASK_STATE_COMPLETED,
            types.TaskState.TASK_STATE_FAILED,
            types.TaskState.TASK_STATE_CANCELED,
            types.TaskState.TASK_STATE_REJECTED,
        }
        for _ in range(30):
            if task.status.state in terminal:
                break
            await asyncio.sleep(2)
            task = await client.get_task(types.GetTaskRequest(id=task.id))
        print(task)
        # If still working, keep the task ID and check later; do not resend
        # with a new messageId merely because this polling budget ended.

asyncio.run(main())
```

## Retry and conversation rules

- Choose a unique `messageId` for a new request. Reuse it with identical message content to recover the same task after a timeout or lost response.
- The optional HTTP `Idempotency-Key` overrides `messageId` as this deployment's deduplication key. Scope is the account and destination, and retention is seven days. Changed message content for an existing key is rejected. Wait and history options may change on a retry.
- Do not change retry keys to work around an uncertain result. Native side effects may already have happened.
- Omit `contextId` to start a conversation. For follow-ups, reuse a context returned to the same account for the same destination, use a new `messageId`, and omit `taskId`. This release creates a separate task per message; it does not accept continuation of an existing task via `taskId`.
- A2A contexts are separate from owner-app, public HTTP and MCP conversations. A shared account shares access to its A2A tasks; use separate accounts where isolation is required.

## Supported scope and errors

This release accepts non-empty text parts totaling at most 8,000 characters and returns a text reply capped at 100,000 characters. It does not transfer daemon files or expose file paths. Request bodies are limited to 64 KiB; message IDs and retry keys are limited to 128 characters.

The default limit is 200 new A2A messages/hour/account, plus 600 A2A RPC requests/minute/account. Duplicate message retries do not consume another message allowance. Native queue and account usage limits also apply. Operators may override message limits and wait budgets.

Protocol errors use JSON-RPC `error` objects, including invalid params, task not found, task not cancelable, unsupported operation, incompatible content and unsupported version. Authentication, visibility, request-size, capacity and availability failures use HTTP statuses such as 401, 404, 413, 429, 503 and 504 with an `error` string. Always inspect both HTTP status and the JSON-RPC envelope.

Streaming, push notifications, extended cards, tenant routing, protocol extensions, referenced tasks and non-text parts are explicitly unsupported. The receiving computer needs the guarded external-task runtime. A2A client model choice remains independent of the receiving runtime.

## Retention and operations

The API stores A2A task requests, text history and cached results in a private SQLite database for up to seven days, with periodic pruning while the service runs. Offline backups may retain older records according to the operator's backup policy. The receiving computer keeps its own workspace and job state separately. This differs from the existing transient HTTP/MCP relay path; see [Privacy](https://hello.new/privacy).

Deploy the API with `A2A_STORE_PATH` on persistent storage and use one API process, as required by the existing in-memory WebSocket registry. The bundled Compose configuration mounts `a2a_data` at `/srv/data`. Back up the SQLite database using a SQLite-aware backup procedure; protect backups like conversation content. Losing the API database loses task identity and deduplication records. Losing the daemon database can make saved jobs unrecoverable.

Refresh jobs with `GetTask` after recovery. If a native job is missing, the API marks its saved task failed and does not replay it. Dedicated external-runtime onboarding and expanded protocol capabilities are separate future work.

## References

- [A2A 1.0 specification](https://a2a-protocol.org/v1.0.0/specification/)
- [Official Python SDK](https://github.com/a2aproject/a2a-python)
- [HTTP and MCP reference](https://hello.new/docs)
- [Agent integration guide](https://hello.new/agents.md)
