startA2AServer
JavaScript · package toolnexus · SPEC §7B · js/src/serve.ts
function startA2AServer(opts: { addr: string a2a?: A2AConfig skills: SkillInfo[] runTask: (text: string, contextId?: string) => Promise<RunResult> onTask?: OnTask mcp?: MCPServeConfig mcpTools?: Tool[] onCall?: OnCall}): Promise<ServeHandle>Stand up the HTTP server behind toolkit.serve(addr, opts) — the low-level function Toolkit.serve
delegates to. When a2a is present it mounts GET /.well-known/agent-card.json (built from
skills via buildAgentCard) and POST / for JSON-RPC
SendMessage/GetTask; SendMessage returns a submitted Task immediately and fulfils it
asynchronously through runTask — your bridge into whatever runs the model. When mcp is also
present it co-mounts POST /mcp (see buildMcpServer) on the same
server. Neither profile present ⇒ every request 404s.
When to use it
Section titled “When to use it”Reach for startA2AServer directly when you are not going through Toolkit.serve — you have your
own runTask (not necessarily a toolnexus Client.run/ask), or you want the raw server without
a Toolkit wrapping it. Most callers use toolkit.serve(addr, { client, a2a }) instead, which
builds runTask from client.run/client.ask and skills/mcpTools from the toolkit for you.
Why this and not the alternative
Section titled “Why this and not the alternative”Prefer startA2AServer directly when:
runTaskshould do something other thanclient.run/client.ask— a queue handoff, a different agent framework entirely, a canned responder for a test double.- You want the MCP profile (
mcp) without an A2A profile on the same port —a2ais optional, and an MCP-only server 404s every other route.
Examples
Section titled “Examples”1. The smallest useful call — a2a absent, everything 404s
Section titled “1. The smallest useful call — a2a absent, everything 404s”a2a and mcp are both optional; with neither set, the server still starts and listens, it just
answers 404 to everything. This is the documented “opt-in” behavior, not a misconfiguration.
import assert from "node:assert"import { startA2AServer } from "toolnexus"
const srv = await startA2AServer({ addr: "127.0.0.1:0", skills: [], runTask: async () => ({ text: "unused", status: "done" } as any),})
const res = await fetch(srv.url + "/.well-known/agent-card.json")assert.equal(res.status, 404, "no profile mounted ⇒ no routes")
await srv.stop()console.log("ok:", srv.url)2. A real, hermetic round trip — served toolkit called by a real A2A caller
Section titled “2. A real, hermetic round trip — served toolkit called by a real A2A caller”The realistic path: toolkit.serve (which calls startA2AServer under the hood) against a stub
LLM, then a second toolkit’s agent() calling it — the same round trip
agent’s example 2 walks through, shown here from the serving
side.
import assert from "node:assert"import http from "node:http"import { agent, createClient, createToolkit } from "toolnexus"
const llm = http.createServer((_req, res) => { res.writeHead(200, { "content-type": "application/json" }) res.end(JSON.stringify({ choices: [{ message: { content: "TRANSCRIBED" } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }))})await new Promise<void>((r) => llm.listen(0, "127.0.0.1", r))const llmPort = (llm.address() as any).port
const tk = await createToolkit({ skillsDir: "./examples/skills", builtins: false })const client = createClient({ baseUrl: `http://127.0.0.1:${llmPort}`, style: "openai", model: "x", apiKey: "k" })
// Under the hood this calls startA2AServer({ addr, a2a, skills: tk's SkillInfo[], runTask: (t) => client.run(t, { toolkit: tk }) }).const srv = await tk.serve("127.0.0.1:0", { client, a2a: { name: "video-desk", skills: ["hello-world"] } })
const card = await (await fetch(srv.url + "/.well-known/agent-card.json")).json()assert.equal(card.name, "video-desk")assert.equal(card.url, srv.url + "/", "card.url is the JSON-RPC POST endpoint")
const caller = await createToolkit({ builtins: false, agents: [agent({ card: srv.url + "/.well-known/agent-card.json", pollEvery: 10 })],})try { const res = await caller.execute("video-desk_hello-world", { task: "do it" }) assert.equal(res.isError, false) assert.equal(res.output, "TRANSCRIBED", "submit → poll → runTask → artifact, end to end") console.log("ok:", res.output)} finally { await caller.close() await srv.stop() await tk.close() llm.close()}3. The full surface — raw JSON-RPC, onTask telemetry, and a fulfilment error surviving
Section titled “3. The full surface — raw JSON-RPC, onTask telemetry, and a fulfilment error surviving”Calling startA2AServer directly with a custom runTask (no Client involved at all), talking
raw JSON-RPC to SendMessage/GetTask, and observing onTask fire on both success and a
fulfilment error — which becomes a failed Task, never a crashed server.
import assert from "node:assert"import { startA2AServer, type RunResult } from "toolnexus"
const events: any[] = []let calls = 0
const srv = await startA2AServer({ addr: "127.0.0.1:0", skills: [{ name: "echo", description: "Echoes the task", location: "-", content: "" }], a2a: { name: "custom-runner" }, runTask: async (text: string): Promise<RunResult> => { calls++ if (text.includes("boom")) throw new Error("runner exploded") return { text: `echo: ${text}`, status: "done" } as RunResult }, onTask: (ev) => events.push(ev),})
async function rpc(method: string, params: unknown) { const res = await fetch(srv.url + "/", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: "1", method, params }), }) return res.json() as Promise<any>}async function pollUntilTerminal(id: string): Promise<any> { for (let i = 0; i < 50; i++) { const env = await rpc("GetTask", { id }) const state = env.result?.status?.state if (state === "completed" || state === "failed") return env.result await new Promise((r) => setTimeout(r, 10)) } throw new Error("never terminal")}
// Success path.const ok = await rpc("SendMessage", { message: { role: "user", parts: [{ kind: "text", text: "hi" }] } })assert.equal(ok.result.status.state, "submitted", "SendMessage returns immediately, fulfilment is async")const done = await pollUntilTerminal(ok.result.id)assert.equal(done.status.state, "completed")assert.equal(done.artifacts[0].parts[0].text, "echo: hi")
// Failure path — never crashes the server.const bad = await rpc("SendMessage", { message: { role: "user", parts: [{ kind: "text", text: "boom please" }] } })const failed = await pollUntilTerminal(bad.result.id)assert.equal(failed.status.state, "failed")assert.match(failed.status.message.parts[0].text, /runner exploded/)
assert.equal(calls, 2)assert.deepEqual(events.map((e) => e.state), ["completed", "failed"])
await srv.stop()console.log("ok:", events.map((e) => e.state).join(","))Options
Section titled “Options”| Field | Type | What it does |
|---|---|---|
addr |
string |
host:port to bind. Port 0 picks an ephemeral free port. Required. |
a2a |
A2AConfig |
{ name?, description?, version?, provider?, skills?, store? }. Absent ⇒ no A2A routes. |
skills |
SkillInfo[] |
The toolkit’s SkillSource entries — what the Agent Card advertises. |
runTask |
(text, contextId?) => Promise<RunResult> |
Fulfils one Task’s text against the model. contextId (from the A2A message) threads conversation memory. Required. |
onTask |
(ev: OnTaskEvent) => void | Promise<void> |
Fires on every terminal Task state with the RunResult telemetry. |
mcp |
MCPServeConfig |
Co-mounts POST /mcp (§7C). Absent ⇒ no MCP route. |
mcpTools |
Tool[] |
The toolkit’s unified tools, exposed by the mcp profile. |
onCall |
(ev: OnCallEvent) => void | Promise<void> |
Fires on every inbound tools/call. |
What you get back
Section titled “What you get back”Promise<ServeHandle> — { url, stop(), close() }. url is the base URL of the listening server
(0.0.0.0/:: report back as 127.0.0.1); close() is an alias for stop(). A RunResult with
status: "pending" maps the Task to "input-required" (§10 crossing A2A) rather than a false
"completed".
See also
Section titled “See also”buildAgentCard— Construct the Agent Card that advertises your name, skills and endpoint.FileTaskStore— Persist inbound A2A tasks so a suspended request survives a restart.buildMcpServer— The inbound MCP profile: any MCP client can call your tools.agent— the outbound side: call a server started this way