Skip to main content

A2A — calling other agents

@ilmek/a2a and Ilmek.A2A call an Agent2Agent agent from inside a graph. The remote agent's reply comes back as plain data, every round-trip is journaled so a pause/resume never re-sends a message the other agent already acted on, and a remote agent's own human-in-the-loop pause arrives as an input-required task the node can answer, escalate to its own human, or abandon. One node type lets a stored graph spec delegate to an agent by name.

ilmek stays LLM-agnostic: nothing here calls a model. The other direction — serving a graph as an A2A agent — is mekik's job: mekik → Serving → A2A agent. Both implementations are held to one result by the scripted agent in conformance/a2a.

Install and connect​

npm install @ilmek/core @ilmek/a2a

Zero dependencies — the HTTP transport rides the global fetch.

import { A2aAgent, HttpA2aTransport } from "@ilmek/a2a";

// An origin (card at /.well-known/agent-card.json), a card URL, or the JSON-RPC endpoint.
const transport = new HttpA2aTransport("https://desk.example.com", { headers: { Authorization: "Bearer …" } });
const desk = await A2aAgent.connect(transport, { name: "desk" }); // fetches the card once
console.log(desk.card.name, desk.card.skills?.map((s) => s.id));

connect fetches the Agent Card once; name is the journal-key namespace (default: the card's name, slugified). The transport is a two-call port — getAgentCard and post — so a scripted fake stands in for tests.

Sending a message — once​

import { channel, END, graph, START } from "@ilmek/core";

const g = graph("delegate")
.channel("question", channel.lastWrite<string>(""))
.channel("log", channel.append<string>())
.node("ask", async (state, ctx) => {
// Journaled under `a2a:desk:send`: on the resume pass after the interrupt
// below, the recorded task comes back and the desk is not messaged again.
const first = await desk.send(ctx, state.question);

if (!first.needsInput) return { log: [first.text] };

// The remote agent paused for a human. Escalate to ours…
const answer = await ctx.interrupt<string>({ question: first.statusText });
// …and answer the remote task. A second send needs its own journal key.
const done = await desk.send(ctx, answer, { taskId: first.taskId, key: "answer" });
return { log: [done.text] };
})
.edge(START, "ask").edge("ask", END)
.compile();

The result is normalized to plain data — that is what the journal keeps:

fieldmeaning
taskId, contextIdthe remote task and conversation; pass contextId to continue, taskId to answer
statethe A2A task state: completed, input-required, failed, rejected, canceled, …
textthe text of the task's artifacts (the reply), joined
statusTextthe agent's status message — what it needs, or why it stopped
statusDatathe status message's first data part (a mekik agent puts its open interrupts here as pending)
needsInputstate === "input-required"
taskthe task, verbatim

send takes contextId to continue a remote conversation, taskId to answer a paused task, data for a data part (a mekik agent with several open pauses wants { answers: { <id>: <answer> } }), and key to separate two sends in one node. invoke is the raw, unjournaled call. A JSON-RPC error from the remote agent throws A2aError / A2aException with its code (-32001 task not found, …); an agent that answers message/send with a bare message is read as a completed task.

A2A as graph data​

A stored graph spec never carries executable text, so an agent is referenced by name and resolved through the node registry at build time. a2aNodes(agents) / A2aNodes.Registry(agents) adds one node type:

typeconfigdoes
a2a_call{ agent, text? | textFrom, contextFrom?, taskFrom?, to?, textOnly? }sends text (or channel textFrom) to agent, continuing the remote conversation in channel contextFrom and answering the task in channel taskFrom when set, journaled; writes the result — or just its text — to channel to (default "result")
import { END, fromSpec, run, START } from "@ilmek/core";
import type { GraphSpec } from "@ilmek/core";
import { a2aNodes } from "@ilmek/a2a";

const spec: GraphSpec = {
name: "delegate",
channels: { question: {}, answer: {} },
nodes: [{ id: "ask", type: "a2a_call", config: { agent: "desk", textFrom: "question", to: "answer", textOnly: true } }],
edges: [{ from: START, to: "ask" }, { from: "ask", to: END }],
};

const g = fromSpec(spec, a2aNodes({ desk })).compile();
const result = await run(g, { question: "Where is ORD-42?" }, { threadId: "t-1" });
console.log(result.state?.answer);

Conformance​

conformance/a2a/agent.json scripts a remote agent; both implementations build a fake transport from it, connect, send every scripted message and compare the normalized results with expected.json as canonical JSON. Journaling, the HTTP transport's URL rules and the node type are asserted inline in each suite.