Integrating via A2A
Reach your DorkOS agents from external tools over Google's Agent-to-Agent (A2A) protocol
Integrating via A2A
The A2A gateway exposes your registered DorkOS agents to any external client that speaks Google's Agent-to-Agent (A2A) protocol. A2A is a JSON-RPC surface. An external orchestrator discovers your agents through an Agent Card, then sends messages to them over a standard method set.
This is developer reference: it assumes you're wiring up an external A2A client against a running DorkOS instance, not just using DorkOS day to day.
Protocol versions. DorkOS speaks A2A v1.0, and still accepts the older v0.3 calls, so a client written against either version works. Agent Cards advertise both, and each request is answered in the version it was asked in. The examples below come in both dialects — pick the one matching the SDK you have installed.
A2A is an external, network-facing surface. It is off by default and, when enabled, is guarded so it never exposes unauthenticated prompt execution to the network. Read Deployment security before binding it beyond loopback.
Enabling the gateway
The gateway is off until you turn it on, and it needs Relay running. There are two ways to turn it on.
In the app. Open Settings → Experiments and flip "Let outside agents reach yours". DorkOS reads it once when it starts, so restart to pick up the change.
By environment variable, for a headless deployment:
DORKOS_RELAY_ENABLED=true
DORKOS_A2A_ENABLED=trueDORKOS_A2A_ENABLED wins over the setting whenever it is present — including when it says false. The switch in Settings shows what is really happening on that machine and goes read-only, rather than offering you a choice the server will ignore.
On startup you'll see a log line confirming the mount point and auth mode:
[A2A] Gateway mounted (fleet card: /.well-known/agent-card.json, RPC: POST /a2a, auth: none (loopback))Here auth: none means you've set no MCP_API_KEY and haven't turned on login. JSON-RPC execution is still token-gated in that mode — it needs the local MCP token (see Authentication); only the loopback bind is what "loopback" refers to.
Discovery: the Agent Card
A2A clients start by fetching an Agent Card: a JSON document describing what an agent is and where to reach it.
Fleet card
GET /.well-known/agent-card.json returns a single card that represents your whole DorkOS fleet. This is the spec-standard discovery path (AGENT_CARD_PATH). The pre-spec /.well-known/agent.json path is kept as a legacy alias during the transition.
Per-agent cards
GET /a2a/agents/:id/card returns a card for one specific agent (by its mesh ULID). Each card lists that agent's own JSON-RPC endpoint (/a2a/agents/:id) under supportedInterfaces, so a client that discovered a per-agent card never needs to specify which agent it's talking to. The endpoint is listed twice — once as protocol version 1.0 and once as 0.3 — which is how a client of either version knows it can talk to us.
Sending messages
There are two ways to target an agent:
| Endpoint | Targeting |
|---|---|
POST /a2a/agents/:id | The agent is bound from the URL. No metadata.agentId needed (a conflicting one is rejected). |
POST /a2a | Fleet endpoint. Every message must carry metadata.agentId (or continue a task via taskId). |
The fleet endpoint never routes an untargeted message to an arbitrary agent. A missing agentId returns an actionable JSON-RPC -32602 (Invalid params) error.
Supported methods, in both dialects:
| v1.0 | v0.3 | What it does |
|---|---|---|
SendMessage | message/send | Send a message, get a task |
SendStreamingMessage | message/stream | The same, streamed over SSE |
GetTask | tasks/get | Look a task up |
CancelTask | tasks/cancel | Stop a running task |
ListTasks | — | List tasks, newest first |
ListTasks answers for whichever endpoint you asked. On /a2a/agents/:id you get that
agent's tasks and nothing else — the count and the paging follow the same scope. On the fleet
endpoint /a2a you get every agent's tasks. Filter either one by contextId, by status, or
by statusTimestampAfter; artifacts are left out unless you ask for them with
includeArtifacts.
Looking a task up follows the same boundary. On an agent's own endpoint, GetTask and
CancelTask answer for that agent's tasks and report anything else as not found — so knowing
another task's id gets you nothing there. Use the fleet endpoint /a2a to reach a task
whichever agent ran it.
Waiting for the answer works differently in each version. In v1.0, a send waits for the
task to finish unless you set configuration.returnImmediately: true. In v0.3 it's the other way
round: a send returns as soon as the task exists unless you set configuration.blocking: true. If
a v0.3 call that used to block now hands you a working task, that's this difference — set
blocking: true and it waits again.
Canceling a task
tasks/cancel asks whoever is running the turn to stop it, and answers with what really
happened. You get the task back marked canceled only when a runner takes the request and
starts interrupting the agent. If nothing takes it — the turn already finished, or DorkOS
restarted and lost track of it — you get a TaskNotCancelable error instead, and the task
keeps the state it had.
So a canceled answer means the stop reached the agent that was working, not merely that
your request was filed. It stops one step short of a receipt from the model itself: in the
rare case where a runtime ignores its own interrupt, DorkOS has no way to tell you.
If a call to message/send waits more than two minutes for an answer, DorkOS gives up and
asks the agent to stop as well, so a caller walking away doesn't leave a model running.
Authentication
Every A2A JSON-RPC call runs a prompt against one of your agents, so execution is always token-gated. While login is off, fetching an Agent Card (the GET discovery paths) stays open — that's public metadata, the A2A analogue of listing tools. Turn login on and card reads need a credential too, so send your token on card fetches as well as on calls.
Which token you send depends on your setup:
- Login off (the default): send the local MCP token as a Bearer header. It's the same token the MCP endpoint uses — find it in Settings → Tools → Connect other apps to DorkOS, or in the
mcp-local-tokenfile in your DorkOS data folder (~/.dork/by default). Without it, a JSON-RPCPOSTcomes back401; an Agent CardGETstill works. - Headless deployments: set
MCP_API_KEYand send it as the Bearer token. It overrides everything else. - Login on: a per-user Better Auth API key works as the Bearer token.
Authorization: Bearer <token>Agent Cards advertise the spec-standard http/bearer security scheme.
contextId: grouping turns into a session
Set message.contextId to group related messages into one continuing agent session; reuse the same contextId across calls to keep context. A2A-originated sessions are keyed on agentId + contextId.
contextId is a shared secret, not a per-caller boundary. Under a single static
MCP_API_KEY, any caller who learns another caller's contextId can deliberately join that
session. Use unguessable values (UUIDs) and treat contextId as a secret shared between a caller
and the gateway. Per-principal isolation is future work.
Quick check with curl
Before wiring up a client, confirm the gateway responds. Fetch the fleet card, grab an agent id from it, then send that agent a message:
# Fleet card: lists your registered agents
curl http://localhost:6242/.well-known/agent-card.json
# Send a message to one agent (swap in a real id and MCP_API_KEY)
curl -X POST http://localhost:6242/a2a/agents/01HZB1AGENTULID0000001 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $MCP_API_KEY" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "SendMessage",
"params": {
"message": {
"role": "ROLE_USER",
"messageId": "11111111-1111-1111-1111-111111111111",
"parts": [{ "text": "ping" }]
}
}
}'The same call in the older v0.3 dialect, which the gateway still accepts:
curl -X POST http://localhost:6242/a2a/agents/01HZB1AGENTULID0000001 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $MCP_API_KEY" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": {
"message": {
"kind": "message",
"role": "user",
"messageId": "11111111-1111-1111-1111-111111111111",
"parts": [{ "kind": "text", "text": "ping" }]
},
"configuration": { "blocking": true }
}
}'A copy-paste client (@a2a-js/sdk)
This talks to a single agent through its per-agent endpoint, injecting the bearer token via a custom fetch. Verified against @a2a-js/sdk@1.0.
import {
ClientFactory,
DefaultAgentCardResolver,
JsonRpcTransportFactory,
} from '@a2a-js/sdk/client';
import { Role, TaskState, type Task } from '@a2a-js/sdk';
import { randomUUID } from 'node:crypto';
const BASE = 'http://localhost:6242'; // your DorkOS host:port (or public URL)
const API_KEY = process.env.MCP_API_KEY ?? '';
// Inject `Authorization: Bearer <key>` on every A2A request.
const authedFetch: typeof fetch = (input, init = {}) =>
fetch(input, {
...init,
headers: { ...(init.headers ?? {}), Authorization: `Bearer ${API_KEY}` },
});
// 1. Discover the fleet and pick an agent id (metadata.agentId on each entry).
const fleetCard = await (await authedFetch(`${BASE}/.well-known/agent-card.json`)).json();
console.log('Fleet card:', fleetCard.name);
// 2. Build a client from a per-agent card. The factory reads the card's
// `supportedInterfaces` and dials that agent's own RPC endpoint.
//
// `authedFetch` has to be given TWICE. The transport uses one fetch to send
// messages; downloading the card is a separate step with its own resolver,
// which falls back to the global fetch. Pass it only to the transport and
// card discovery goes out with no `Authorization` header — fine while login
// is off, since card reads are open then, and a 401 the moment you turn
// login on.
const agentId = '01HZB1AGENTULID0000001'; // a mesh ULID
const factory = new ClientFactory({
transports: [new JsonRpcTransportFactory({ fetchImpl: authedFetch })],
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authedFetch }),
});
const client = await factory.createFromUrl(`${BASE}/a2a/agents/${agentId}/card`, '');
// 3. Send a message. `contextId` groups turns into one session: keep it secret.
// A send waits for the task to finish unless you ask it not to.
const contextId = randomUUID();
const result = await client.sendMessage({
tenant: '',
message: {
messageId: randomUUID(),
contextId,
taskId: '',
role: Role.ROLE_USER,
parts: [
{
content: { $case: 'text', value: 'Run the test suite and summarize any failures.' },
metadata: undefined,
filename: '',
mediaType: 'text/plain',
},
],
metadata: undefined,
extensions: [],
referenceTaskIds: [],
},
configuration: undefined,
metadata: undefined,
});
// The result is a Task (async work) or a Message (direct reply).
const task = result as Task;
console.log(task.status?.state === TaskState.TASK_STATE_COMPLETED ? 'done' : task.status?.state);To use the fleet endpoint instead, drop the per-agent card and set metadata: { agentId } on the message. The SDK will post to /a2a.
Still on @a2a-js/sdk@0.3? Keep using its A2AClient — DorkOS answers those calls too. The v1.0
SDK removed that class, so the code above is the shape you'll want once you upgrade.
Deployment security
The gateway is engineered to fail closed. Understand these before exposing it:
- Exposure guard. On a non-loopback
DORKOS_HOSTwith no auth configured (noMCP_API_KEY, no legacy compat key, login disabled), the server refuses to mount the A2A gateway and its well-known card routes, and logs the fix: setMCP_API_KEYor enable login. DorkOS itself stays usable on your own machine.DORKOS_ALLOW_INSECURE_BIND=trueoverrides for containers that own their network boundary. - Advertised URL. Behind a proxy or tunnel, set
DORKOS_PUBLIC_URLso cards advertise a routable URL instead of the non-routablehttp://0.0.0.0:PORTbind. - Rate limiting assumes one trusted proxy. JSON-RPC endpoints are limited to ~60 req/min/IP and card endpoints to ~300 (
DORKOS_A2A_RPC_RATE_LIMIT/DORKOS_A2A_CARD_RATE_LIMIToverride). The app trusts a single proxy hop (X-Forwarded-For), correct behind one reverse proxy or tunnel (e.g. ngrok). On a direct public bind a client can spoofX-Forwarded-Forto spread requests across buckets, so the limiter is not a security boundary there. Put a trusted proxy in front, or rely on auth. contextIdis not a per-principal boundary: see the caveat above.
Request/response shapes follow the A2A specification. The gateway speaks standard JSON-RPC 2.0. Errors you'll actually see: -32602 (Invalid params, e.g. an untargeted fleet message or a per-agent call with a conflicting metadata.agentId), -32601 (unknown method), A2A task errors (TaskNotFoundError, TaskNotCancelableError) on the tasks/* methods, plus plain HTTP 401 when auth is enforced and the bearer token is missing or wrong, and 429 from the rate limiter.