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
- TypeScript
- .NET
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.
var agent = new MekikA2aServer(app, new A2aServerOptions
{
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 = catalog.List().Select(s => new SkillSummary { Name = s.Name, Description = s.Description }).ToList(),
});
web.MapMekik("/ws", app); // humans
web.MapMekikA2a("/a2a", agent); // agents — card at /.well-known/agent-card.json
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:
| message | effect |
|---|---|
no taskId, no contextId | a new conversation, a new task: one turn |
contextId from a previous task | a new task on that conversation: another turn |
taskId of an input-required task | the 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.