A2A (Agent-to-Agent) Protocol Support

May 29, 2026 · View on GitHub

aistack speaks the A2A protocol v1 so that aistack agents can interoperate with agents written in CrewAI 1.10, Microsoft Agent Framework 1.0, the OpenAI Agents SDK, Mastra, and Letta (Agent File .af). This document covers the on-the-wire format, how to expose your aistack agents as A2A endpoints, and how to call remote A2A agents from your own code or workflows.

Protocol overview

A2A is a small HTTP/JSON protocol with two compulsory surfaces:

SurfaceMethodPathPurpose
Agent cardGET/.well-known/a2a-agent-card.jsonCapability discovery
MessagePOST/v1/a2a/messageSend a task to the agent

aistack implements v1.0 of the spec (protocolVersion: "1.0"). Streaming, push notifications, and state-transition history are advertised as false in the capabilities block; they will be added in a follow-up issue.

Agent card schema

A canonical aistack agent card looks like:

{
  "protocolVersion": "1.0",
  "name": "aistack",
  "description": "aistack multi-agent orchestrator exposed via the A2A protocol for cross-runtime interop.",
  "url": "http://127.0.0.1:8787",
  "version": "1.0.0",
  "capabilities": {
    "streaming": false,
    "pushNotifications": false,
    "stateTransitionHistory": false
  },
  "authentication": { "schemes": ["bearer"] },
  "defaultInputModes": ["text"],
  "defaultOutputModes": ["text"],
  "skills": [
    {
      "id": "coder",
      "name": "Coder",
      "description": "Writes code based on specifications",
      "inputModes": ["text"],
      "outputModes": ["text"],
      "tags": ["write-code", "refactor", "fix-bugs"]
    },
    {
      "id": "reviewer",
      "name": "Reviewer",
      "description": "Reviews code for correctness, quality, and best practices",
      "inputModes": ["text"],
      "outputModes": ["text"],
      "tags": ["code-review", "quality-check"]
    }
  ]
}

Each aistack agent type registered in the registry (src/agents/registry.ts) becomes one A2A skill. Limit which agents are exposed by setting a2a.exposedAgents in aistack.config.json.

Message schema

Inbound (POST /v1/a2a/message):

{
  "messageId": "11111111-2222-3333-4444-555555555555",
  "skillId": "coder",
  "role": "user",
  "parts": [{ "kind": "text", "text": "Fix the null pointer in src/foo.ts" }]
}

Successful response (HTTP 200):

{
  "messageId": "66666666-7777-8888-9999-aaaaaaaaaaaa",
  "inReplyTo": "11111111-2222-3333-4444-555555555555",
  "role": "agent",
  "status": "completed",
  "parts": [{ "kind": "text", "text": "<agent output>" }],
  "metadata": { "skillId": "coder" }
}

Error responses (4xx/5xx) follow the shape:

{ "error": "unauthorized", "message": "Missing or malformed Authorization header" }

Server setup

The A2A server registers two routes onto an A2ARouter — a dedicated multi-route HTTP listener for A2A endpoints. AIG-636's WebhookServer (the daemon task-ingestion endpoint, pinned to POST /v1/tasks) and the SCM IntegrationRouter from AIG-637 run on their own listeners so each protocol surface owns its routing table.

CLI

# Bind to loopback on port 8787 with bearer auth from env var
export AISTACK_A2A_TOKEN="$(openssl rand -hex 32)"
aistack a2a serve --port 8787

# Public URL (e.g. behind reverse proxy)
aistack a2a serve --port 8787 --url https://agents.example.com

# Local-only with no auth (DEV ONLY)
aistack a2a serve --port 8787 --no-auth

Programmatic

import { A2ARouter } from '@blackms/aistack/transport/a2a-router';
import { registerA2ARoutes } from '@blackms/aistack/a2a';
import { runAgent } from '@blackms/aistack/agents';
import { getConfig } from '@blackms/aistack';

const config = getConfig();
const server = new A2ARouter({ port: 8787, host: '127.0.0.1' });

registerA2ARoutes(server, {
  config,
  a2a: {
    url: 'https://agents.example.com',
    bearerToken: process.env.AISTACK_A2A_TOKEN,
    exposedAgents: ['coder', 'reviewer'], // optional allowlist
  },
  executor: async (skillId, prompt) => {
    const result = await runAgent(skillId, prompt, config);
    return result.response;
  },
});

await server.start();

Configuration

aistack.config.json accepts an optional a2a block:

{
  "a2a": {
    "enabled": true,
    "port": 8787,
    "host": "127.0.0.1",
    "publicUrl": "https://agents.example.com",
    "bearerToken": "${AISTACK_A2A_TOKEN}",
    "exposedAgents": ["coder", "reviewer"]
  }
}

${ENV_VAR} placeholders are interpolated by loadConfig().

Client usage

CLI

# Call a remote A2A agent
export AISTACK_A2A_CLIENT_TOKEN="..."
aistack a2a call https://crew.example.com "Plan a sprint about onboarding"

# Specify the target skill
aistack a2a call https://crew.example.com "..." --skill planner

# Inspect a remote agent's card
aistack a2a card https://crew.example.com

Programmatic

import { a2aCall, fetchAgentCard, textMessage } from '@blackms/aistack/a2a';

// Discover capabilities
const card = await fetchAgentCard('https://crew.example.com');
console.log(card.skills.map((s) => s.id));

// Call with auto-wrapped string
const r = await a2aCall('https://crew.example.com', 'plan my sprint', {
  bearerToken: process.env.CREW_TOKEN,
});

// Call with a fully-formed message
const r2 = await a2aCall(
  'https://crew.example.com',
  textMessage(crypto.randomUUID(), 'plan my sprint', 'planner'),
  { bearerToken: process.env.CREW_TOKEN, timeoutMs: 60_000, retries: 1 },
);

a2aCall retries on network failures and 5xx responses (default 2 retries with exponential backoff). 4xx errors are surfaced immediately as A2AClientError — they will not get better with retries.

Security model

ConcernDefaultHow to harden
Network exposure127.0.0.1Front with a reverse proxy + TLS
AuthenticationBearer token from AISTACK_A2A_TOKENRotate with secrets manager; advertise mtls in card if required
AuthorizationAllowlist via exposedAgentsPin to least-privilege subset
Replay protectionProcess-local messageId deduplication with 5 minute TTL and LRU capacityUse short-lived JWTs or upstream nonce tracking for multi-process, clustered, or persistent replay guarantees
AuditStandard logger outputPlug into existing aistack monitoring (src/monitoring)

Never hardcode bearer tokens. The CLI reads them from AISTACK_A2A_TOKEN (server) and AISTACK_A2A_CLIENT_TOKEN (client). The config schema accepts ${ENV} interpolation for the same reason. If no token is configured the server logs a warning at startup and disables auth.

Interop examples

Calling a CrewAI 1.10 endpoint from aistack

import { a2aCall } from '@blackms/aistack/a2a';

const result = await a2aCall(
  'https://my-crew.example.com',
  'Generate Q3 OKRs for the platform team',
  { bearerToken: process.env.CREW_TOKEN, retries: 1 },
);
console.log(result.parts[0].text);

Calling an aistack endpoint from CrewAI

From a CrewAI 1.10 process, register the aistack endpoint as an A2A peer (using whatever A2A client wrapper your runtime exposes):

from crewai.a2a import A2AClient

client = A2AClient("https://agents.example.com",
                   token=os.environ["AISTACK_TOKEN"])
card = client.fetch_card()
# Pick any aistack skill listed in card.skills
response = client.send_message(
    skill_id="coder",
    text="Refactor src/foo.ts to use async/await",
)
print(response.parts[0].text)

Replace crewai.a2a.A2AClient with the equivalent helper from Microsoft Agent Framework, OpenAI Agents SDK, Mastra, or Letta — the wire protocol is the same.

Acceptance criteria checklist

  • Agent card JSON spec valid (A2A v1) — generateAgentCard() round-trips through Zod
  • Server endpoint functional — registerA2ARoutes() on A2ARouter
  • Client TS API a2aCall(url, msg) returns response — src/a2a/client.ts
  • E2E roundtrip aistack <-> CrewAI mock — tests/integration/a2a-roundtrip.test.ts
  • Documented — this file
  • CLI aistack a2a serve exposes the agent — src/cli/commands/a2a.ts