Control Tower v0.2.1

A2A agents

A2A (Agent2Agent) is the open protocol agents use to talk to other agents: a remote agent publishes an Agent Card describing its skills and endpoint, and callers send it messages and follow its tasks over JSON-RPC. Control Tower stands in front of such an agent. Callers find it through a card Control Tower publishes, reach it with their own Control Tower key, and every message and task call is a flight: recorded, drawn on the Airspace, and open to gates, approvals and inspect gates like any tool call.

Quick reference

RegisterA2A agents → Add agent, with the agent's Agent Card URL or base URL
The card callers use<control tower>/a2a/<slug>/.well-known/agent-card.json (also …/agent.json)
The endpoint callers usePOST <control tower>/a2a/<slug> — JSON-RPC
AuthenticationThe caller's Control Tower key as Authorization: Bearer ct_sk_…; the agent's own credentials are added by Control Tower
VersionsA2A 1.0 (SendMessage, GetTask, …) and 0.3 (message/send, tasks/get, …), over the JSON-RPC binding
On the mapA destination station, A2A agent, with a row for each method called
In gatesTools named <slug>__<Method> — research__SendMessage, research__GetTask
DelegationWith an Agent ID, the agent is sent a token (in params.metadata and the x-ct-delegation header) to pass on with its own calls — see Agents calling agents

Step 1: Register the agent

Under A2A agents, click Add agent. Give it a name and a slug (its path under /a2a/), and the URL of its Agent Card — or the agent's base URL, under which /.well-known/agent-card.json is looked for. If the agent asks callers for a token, add it under Credentials: it is encrypted at rest, and calling agents never see it.

Registering an A2A agent

Control Tower reads the card, finds the agent's JSON-RPC endpoint and lists its skills. Agent ID is the agent ID on the remote agent's own Control Tower key, if it has one: its own model and tool calls then join it on the map, and it is sent a delegation token so they count as made on the caller's behalf. Leave it empty for an agent outside Control Tower; it is then a destination only.

The API equivalent, with CT and ADMIN set as in the API reference:

export CT=http://localhost:4000
export ADMIN="Authorization: Bearer $CT_ADMIN_KEY"
curl -s $CT/admin/api/a2a/agents -H "$ADMIN" -H 'content-type: application/json' \
  -d '{"name": "Research agent", "slug": "research", "url": "https://research.internal.example.com",
       "auth": {"type": "bearer", "token": "…"}}'

Step 2: Point the calling agent at Control Tower

Give the calling agent the card at Control Tower instead of the agent's own, and its Control Tower key:

https://tower.example.com/a2a/research/.well-known/agent-card.json
Authorization: Bearer ct_sk_…

The published card is the agent's own — name, description, skills, capabilities — with its interface pointing at https://tower.example.com/a2a/research and a bearer security scheme asking for a Control Tower key. Any A2A client that reads the card then sends everything through Control Tower. Fetching the card needs a key too, so an agent's description and skills aren't open to anyone who can reach Control Tower; A2A clients send headers for the card request the same way they do for calls.

Give clients the full card URL, not …/a2a/research as a base URL: A2A clients such as the official JavaScript SDK look for /.well-known/agent-card.json at the root of the host they're given, which here is Control Tower itself. With the official JavaScript SDK (@a2a-js/sdk):

import { ClientFactory, ClientFactoryOptions, DefaultAgentCardResolver, JsonRpcTransportFactory } from '@a2a-js/sdk/client';

// Every request — the card and the calls — carries the agent's Control Tower key.
const withKey: typeof fetch = (input, init) => {
  const headers = new Headers(init?.headers);
  headers.set('authorization', `Bearer ${process.env.CT_KEY}`);
  return fetch(input, { ...init, headers });
};
const factory = new ClientFactory(
  ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
    transports: [new JsonRpcTransportFactory({ fetchImpl: withKey })],
    cardResolver: new DefaultAgentCardResolver({ fetchImpl: withKey }),
  }),
);
const client = await factory.createFromUrl('https://tower.example.com/a2a/research/.well-known/agent-card.json', '');

For an agent on A2A 0.3, add legacyCompat: { enabled: true } to both the transport factory and the resolver.

A call, by hand:

curl https://tower.example.com/a2a/research \
  -H "Authorization: Bearer ct_sk_…" \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "SendMessage",
       "params": {"message": {"messageId": "m1", "role": "ROLE_USER", "parts": [{"text": "What is the refund policy?"}]}}}'

GET /a2a with a key lists the A2A agents that key may reach, with their card URLs.

Step 3: See it

The agent's card lists the methods agents have called it with:

An A2A agent with its skills and the methods called

On the Airspace the agent is a destination — A2A agent — with a row per method, and a line from every agent that calls it:

support-bot calling an A2A agent on the Airspace

Gates

Each method is a tool named <slug>__<Method>, by its A2A 1.0 name whichever version the caller speaks, and counted as a read or a write:

Method (1.0 · 0.3)Operation
SendMessage · message/sendwrite
SendStreamingMessage · message/streamwrite, streamed
GetTask · tasks/get, ListTasks · tasks/listread
CancelTask · tasks/cancelwrite
SubscribeToTask · tasks/resubscriberead, streamed
…TaskPushNotificationConfig… · tasks/pushNotificationConfig/…create and delete write, get and list read
GetExtendedAgentCard · agent/getAuthenticatedExtendedCardread

So a gate on research__SendMessage controls who may give the research agent work, while research__Get* leaves them free to follow the tasks they started. A key's allowed_mcp globs apply too: a key limited to files__* can't reach research__*, and doesn't see it in GET /a2a.

gates:
  - name: Only people approve work for the payments agent
    match:
      tools: ["payments__SendMessage", "payments__SendStreamingMessage"]
    effect: require_approval

A denied call is a JSON-RPC error whose code is the HTTP status, with the reason in data, as the A2A specification describes:

{"jsonrpc": "2.0", "id": 1, "error": {"code": 403, "message": "Blocked by Control Tower policy. …",
  "data": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "POLICY_DENIED", "domain": "controltower",
            "metadata": {"flight_id": "…", "rule_id": "…"}}]}}

A call held for approval that times out answers APPROVAL_REQUIRED with a ticket in metadata; once a person approves it in the Tower, send the same call again with params.metadata.ct_approval (or the x-ct-approval header) set to the ticket.

Inspect gates read the message an agent sends (params.message) before it leaves, and the reply before the caller sees it. Streamed replies (SendStreamingMessage, SubscribeToTask) are relayed as they come and are not inspected. An agent that answers a non-streaming method with a stream on a path with inspect gates is refused (UNEXPECTED_STREAM), so a reply can't slip past them that way.

An approval is bound to the message's content, not its messageId: SDKs make a new messageId for every send, so resending the same message with the ticket works, while a different message is refused.

Push notifications

An agent that works on a task in the background can send the caller push notifications — task updates POSTed to a webhook the caller gives it. They come back through Control Tower:

  • When a caller sets up a webhook — with CreateTaskPushNotificationConfig (tasks/pushNotificationConfig/set in 0.3), or inside a message (configuration.taskPushNotificationConfig) — the agent is given a Control Tower address (/a2a/<slug>/push/…) and a token of its own. It never sees the caller's webhook or token.
  • Each notification the agent sends there is checked against that token, recorded as a call (<slug>__PushNotification, under the call that set it up, on the caller's key), put through gates and inspect gates like anything the caller reads, and delivered to the caller's webhook with the caller's own token (X-A2A-Notification-Token) or credentials (Authorization).
  • A gate on <slug>__PushNotification stops the caller receiving them; an inspect gate reading replies masks or blocks what they carry. Setting up a webhook is a call of its own (<slug>__CreateTaskPushNotificationConfig), gated wherever it's asked for — a message carrying a push configuration passes the same allow-list and gates.
  • When the agent shows a configuration back (get, list), the caller sees its own webhook.
  • Control Tower delivers only to public addresses: a webhook on a private, loopback, link-local, carrier-grade NAT, multicast, documentation or reserved address is refused when it is set up, and checked again on every delivery. That includes every IPv6 way of writing one (IPv4-mapped, NAT64, 6to4) and IPv6's own unique-local, link-local, site-local and Teredo ranges. Set CT_PUSH_ALLOW_PRIVATE=1 when callers and Control Tower share a private network.
  • A relay no notification has used for 30 days is forgotten. CT_A2A_PUSH_RELAY=off lets agents send notifications straight to the caller's webhook instead.

What is and isn't covered

  • JSON-RPC only. An agent that offers only gRPC or HTTP+JSON can't be registered; the error says what it offers.
  • The endpoint must be on the card's server. The agent's credentials go wherever its card says to send calls, so Control Tower only accepts an endpoint with the same origin (scheme, host and port) as the card. If an agent's card lives elsewhere, register the card as its endpoint's server serves it.
  • Replies are capped at 10 MB, and a stream that goes silent for longer than the agent's timeout is ended.
  • A key sees only the agents it may call. GET /a2a and the card both follow the key's allowed_mcp.
  • The agent's card signatures are removed from the published card: it is no longer the card the agent signed.
  • The card is read again every 10 minutes, and on Re-read card. An agent whose card can't be read is marked down and keeps its last good card.
  • Each check also asks the agent's endpoint for a task that doesn't exist (GetTask, or tasks/get for 0.3), with the agent's credentials: any JSON-RPC answer means the agent is there. A card in front of an endpoint that doesn't answer is marked down (its card is fine, but its endpoint answered HTTP 404 without JSON-RPC). A call that finds the endpoint broken or unreachable checks it again at once (at most once a minute).
  • A reply that reports the task failed or rejected is a failed call: its flight is an error (agent_task_failed, agent_task_rejected) with what the agent said — for streams, the last state the stream reported.

Troubleshooting

ErrorCauseFix
401, reason INVALID_API_KEYNo Control Tower key, or the agent's own token was sentSend the Control Tower key as Authorization: Bearer ct_sk_…
404, reason AGENT_NOT_FOUNDUnknown slug, the agent is disabled, or its card was never readCheck A2A agents; click Re-read card
-32601 Method not foundA method that isn't in A2A 1.0 or 0.3Check the method name and the A2A-Version header
403, reason TOOL_NOT_ALLOWEDThe key's allowed_mcp doesn't include <slug>__*Widen the key's allowed_mcp
"offers GRPC … but not JSON-RPC" when registeringThe agent doesn't serve the JSON-RPC bindingEnable JSON-RPC on the agent
502, reason INVALID_AGENT_RESPONSEThe agent's endpoint didn't answer with JSON-RPCCheck the endpoint in its card and the credentials
"sends calls to … a different server from the card's" when registeringThe card's endpoint is on another originRegister the card URL on the endpoint's server
502, reason UNEXPECTED_STREAMThe agent streamed a reply to a non-streaming method, and inspect gates applyCall SendStreamingMessage for streams, or have the agent answer SendMessage with JSON
403, reason POLICY_DENIED, "scope mismatch"The approval ticket was sent with a different messageResend the original message with the ticket

Next steps