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

DORKOS_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:

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, in both dialects:

v1.0v0.3What it does
SendMessagemessage/sendSend a message, get a task
SendStreamingMessagemessage/streamThe same, streamed over SSE
GetTasktasks/getLook a task up
CancelTasktasks/cancelStop a running task
ListTasksList 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-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": "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_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. DorkOS itself stays usable on your own machine. 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