DorkOS
Guides

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=true

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). 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:

EndpointTargeting
POST /a2a/agents/:idThe agent is bound from the URL. No metadata.agentId needed (a conflicting one is rejected).
POST /a2aFleet 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-token file in your DorkOS data folder (~/.dork/ by default). Without it, a JSON-RPC POST comes back 401; an Agent Card GET still works.
  • Headless deployments: set MCP_API_KEY and 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_HOST with no auth configured (no MCP_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: set MCP_API_KEY or enable login. The loopback cockpit stays usable. DORKOS_ALLOW_INSECURE_BIND=true overrides for containers that own their network boundary.
  • Advertised URL. Behind a proxy or tunnel, set DORKOS_PUBLIC_URL so cards advertise a routable URL instead of the non-routable http://0.0.0.0:PORT bind.
  • 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_LIMIT override). 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 spoof X-Forwarded-For to spread requests across buckets, so the limiter is not a security boundary there. Put a trusted proxy in front, or rely on auth.
  • contextId is 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.

Next Steps