Skip to main content

Serving a graph as an A2A agent

Where MCP makes a graph a tool another agent calls, A2A makes it a peer: an Agent2Agent agent with an Agent Card, addressed by messages, answering with tasks. MekikA2aServer does the mapping; @mekik/a2a and Mekik.AspNetCore.MapMekikA2a put it behind HTTP. The wire rules are normative in PROTOCOL.md §14; this page is the serving guide.

The mapping: one mekik conversation is one A2A contextId; one turn is one task. A run that paused for a human is an input-required task, and the next message on that task is the resume — so human-in-the-loop survives the hop to another agent, which can answer itself, escalate to its own human, or give up.

Serve it​

import { mekik, MekikA2aServer } from "@mekik/core";
import { serveA2a } from "@mekik/a2a";

const app = mekik({ graph, reply: (s) => s.reply as string, skills });
const agent = new MekikA2aServer(app, {
name: "Support desk",
description: "Answers questions about orders.",
url: "https://bot.example.com/a2a", // the card's url — where the JSON-RPC endpoint is reachable
version: "1.2.0",
skills: skills.list(), // optional: the app's skill catalog on the card
});
serveA2a(agent, { port: 8901 }); // GET /.well-known/agent-card.json, POST /a2a

a2aRequestHandler(agent) is the bare (req, res) handler for other servers; serveA2a(agent, { server }) attaches to an http.Server you own; path and cardPath move the endpoints.

The Agent Card​

{
"protocolVersion": "0.3.0",
"name": "Support desk",
"description": "Answers questions about orders.",
"url": "https://bot.example.com/a2a",
"preferredTransport": "JSONRPC",
"version": "1.2.0",
"capabilities": { "streaming": false, "pushNotifications": false, "stateTransitionHistory": false },
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{ "id": "chat", "name": "Support desk", "description": "Answers questions about orders.", "tags": ["chat"] },
{ "id": "brand-voice", "name": "brand-voice", "description": "House style.", "tags": [] }
]
}

The agent itself is always the first skill (chat); the app's skills follow when you pass them, so a calling agent reads the same level-1 catalog a model does.

Messages and tasks​

message/send takes an A2A message — { role: "user", parts: [{ kind: "text", text }], contextId?, taskId? } — and returns a task:

messageeffect
no taskId, no contextIda new conversation, a new task: one turn
contextId from a previous taska new task on that conversation: another turn
taskId of an input-required taskthe resume: the message answers the open interrupts
{ "kind": "task", "id": "task-…", "contextId": "conv-…",
"status": { "state": "completed", "timestamp": "2026-09-27T10:00:00.000Z" },
"artifacts": [{ "artifactId": "artifact-…", "name": "reply", "parts": [{ "kind": "text", "text": "Order ORD-42 totals 249.9." }] }],
"history": [ …the user message… ],
"metadata": { "mekik": { "conversationId": "conv-…", "status": "finished", "toolCalls": [ … ], "skills": [ … ] } } }

Task state follows the turn: completed when the run finished (the reply is a text artifact named reply), failed on a graph error, rejected when the engine refused the turn (a message to a parked conversation, a second turn while one runs), canceled after tasks/cancel — and input-required when the run paused:

{ "status": { "state": "input-required", "message": {
"role": "agent", "taskId": "task-…", "parts": [
{ "kind": "text", "text": "The agent needs input before it can continue:\n- interrupt \"agent:interrupt#0\": {\"title\":\"Refund 249.9?\"} — options: Approve, Cancel\nReply on this task: …" },
{ "kind": "data", "data": { "pending": [{ "id": "agent:interrupt#0", "payload": { "title": "Refund 249.9?" }, "actions": [ … ] }] } } ] } },
"metadata": { "pending": [ … ], "mekik": { … } } }

The status message says what is open in prose (for a model) and in a data part (for code). The caller answers by sending on the task: a text message answers a single open interrupt — an action's label maps to its value, anything else is the answer verbatim; a data part { "answers": { "<interrupt id>": … } } answers several at once (required when more than one is open); a lone data part answers a single interrupt with itself. A pause that is a client tool call is listed but cannot be answered from here.

tasks/get returns a task (with historyLength truncating its history); tasks/cancel marks an input-required task canceled — the conversation stays parked on its interrupts, because mekik never discards a pause on a caller's behalf — and refuses a finished one (-32002). message/stream, tasks/resubscribe and push-notification configuration are -32004 UnsupportedOperation; the card says as much.

Both the Agent Card and the JSON-RPC surface are pinned by conformance/a2a/rpc.json, replayed by both suites.

Identity and durability​

Every A2A conversation belongs to one mekik user, "a2a" by default (userId / UserId). Conversations are ordinary mekik conversations — persisted in the history store, resumable from the WebSocket side too. Tasks live in an A2aTaskStore / IA2aTaskStore (in-memory by default; implement the two-method port for durability).

Security​

Like the MCP endpoint, the A2A endpoint carries no authorization of its own; put it behind your gateway. The graph's approvals still gate what a calling agent can make happen — it gets input-required and cannot proceed until something answers.

Where to go next​

  • ilmek → A2A — the other direction: calling A2A agents (this one included) from a node, journaled.
  • MCP server — the same graph as a tool instead of a peer.