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 the cockpit day to day.
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 feature-flag gated and requires Relay:
DORKOS_RELAY_ENABLED=true
DORKOS_A2A_ENABLED=trueOn 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). The url on each per-agent card points at that agent's own JSON-RPC endpoint (/a2a/agents/:id), so a client that discovered a per-agent card never needs to specify which agent it's talking to.
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: message/send, message/stream (SSE), tasks/get, tasks/cancel.
Authentication
Every A2A JSON-RPC call runs a prompt against one of your agents, so execution is always token-gated. Fetching an Agent Card (the GET discovery paths) stays open — that's public metadata, the A2A analogue of listing tools.
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 → External MCP Server, 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": "message/send",
"params": {
"message": {
"kind": "message",
"role": "user",
"messageId": "11111111-1111-1111-1111-111111111111",
"parts": [{ "kind": "text", "text": "ping" }]
}
}
}'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@0.3.
import { A2AClient } from '@a2a-js/sdk/client';
import type { SendMessageResponse } 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: its `url` is that agent's RPC endpoint.
const agentId = '01HZB1AGENTULID0000001'; // a mesh ULID
const client = await A2AClient.fromCardUrl(`${BASE}/a2a/agents/${agentId}/card`, {
fetchImpl: authedFetch,
});
// 3. Send a message. `contextId` groups turns into one session: keep it secret.
const contextId = randomUUID();
const response: SendMessageResponse = await client.sendMessage({
message: {
kind: 'message',
role: 'user',
messageId: randomUUID(),
contextId,
parts: [{ kind: 'text', text: 'Run the test suite and summarize any failures.' }],
},
configuration: { blocking: true },
});
if ('error' in response) {
throw new Error(`A2A error ${response.error.code}: ${response.error.message}`);
}
// response.result is a Task (async work) or a Message (direct reply).
console.log(response.result);To use the fleet endpoint instead, drop the per-agent card and set metadata: { agentId } on the message. The SDK will post to /a2a.
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. The loopback cockpit stays usable.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.