A2AServer.Start
C# · package Toolnexus · SPEC §7B · A2AServer.cs
public Task<ServeHandle> ServeAsync(string addr, Toolkit.ServeOptions opts)Toolkit.ServeAsync is the entry point apps call; under the hood it delegates to
A2AServer.StartAsync, which stands up a minimal HTTP server that — when opts.A2A (or a
top-level a2a config block) is present — mounts GET /.well-known/agent-card.json (built from
the toolkit’s skills, never raw tools) and POST / (JSON-RPC 2.0: SendMessage submits a
Task and fulfils it asynchronously via opts.Client.RunAsync/AskAsync; GetTask polls it). This
is the exact inbound counterpart to A2A.Agent /
A2A.AgentTools — your toolkit becomes the peer being called.
When to use it
Section titled “When to use it”You want your own toolkit reachable by any A2A-speaking caller — another toolnexus instance, a
teammate’s agent, or a third-party A2A client — over plain HTTP, with no bespoke wire format to
maintain. Each of the toolkit’s skills becomes one advertised A2A skill; the JSON-RPC fulfilment
runs the same client.RunAsync/AskAsync loop a local caller would use.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — the card, with defaults
Section titled “1. The smallest useful call — the card, with defaults”using Toolnexus;
using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""));
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Skills = new List<SkillSource.SkillDef> { new("greet", "says hello", "Say a friendly hello.") },});var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",});
var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions{ Client = client, A2A = new A2AConfig(), // all defaults});
using var http = new HttpClient();var body = await http.GetStringAsync(handle.Url + "/.well-known/agent-card.json");var card = Json.ParseObjectLoose(body);
if (card["name"] as string != "toolnexus-agent") throw new Exception(card["name"] as string);if (card["protocolVersion"] as string != "0.3.0") throw new Exception(card["protocolVersion"] as string);if (card["url"] as string != handle.Url + "/") throw new Exception(card["url"] as string);
await handle.StopAsync();Console.WriteLine($"ok: {card["name"]} at {card["url"]}");
sealed class Stub : IDisposable{ readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<System.Net.HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}2. The realistic case — a real local round trip through an outbound A2A caller
Section titled “2. The realistic case — a real local round trip through an outbound A2A caller”using Toolnexus;
using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """{"choices":[{"message":{"content":"the weather is sunny"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""));
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Skills = new List<SkillSource.SkillDef> { new("weather", "reports the weather", "Report the weather.") },});var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",});
var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions{ Client = client, A2A = new A2AConfig { Name = "weather-desk" },});
// Another toolkit, on the caller side, resolves and calls the served toolkit exactly like any// other A2A peer — SendMessage then poll GetTask under the hood.var tools = await A2A.AgentTools(new Agent{ Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25,});var tool = tools.Single(t => t.Name == "weather-desk_weather");var result = await tool.ExecuteAsync(new Dictionary<string, object?> { ["task"] = "how's the weather?" });
if (result.IsError || result.Output != "the weather is sunny") throw new Exception(result.Output);
await handle.StopAsync();Console.WriteLine($"ok: {result.Output}");
sealed class Stub : IDisposable{ readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<System.Net.HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}3. Full surface — onTask telemetry, and no A2A profile ⇒ 404
Section titled “3. Full surface — onTask telemetry, and no A2A profile ⇒ 404”using Toolnexus;
using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """{"choices":[{"message":{"content":"done"}}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}"""));
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Skills = new List<SkillSource.SkillDef> { new("ping", "says done", "Reply done.") },});var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",});
var terminalStates = new List<string>();var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions{ Client = client, A2A = new A2AConfig { Name = "telemetry-desk" }, OnTask = ev => { terminalStates.Add(ev.State); if (ev.Result is not null && ev.State == "completed" && ev.Result.Usage.TotalTokens != 5) throw new Exception($"expected usage 5, got {ev.Result.Usage.TotalTokens}"); return Task.CompletedTask; },});
var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25 });var result = await tools.Single(t => t.Name == "telemetry-desk_ping") .ExecuteAsync(new Dictionary<string, object?> { ["task"] = "ping" });if (result.IsError || result.Output != "done") throw new Exception(result.Output);if (!terminalStates.Contains("completed")) throw new Exception($"onTask never saw completed: {string.Join(",", terminalStates)}");
await handle.StopAsync();
// No A2A profile at all ⇒ the card route (and every A2A route) 404s.await using var barebones = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false });var noA2a = await barebones.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = client });using var http = new HttpClient();var res = await http.GetAsync(noA2a.Url + "/.well-known/agent-card.json");if (res.StatusCode != System.Net.HttpStatusCode.NotFound) throw new Exception(res.StatusCode.ToString());await noA2a.StopAsync();
Console.WriteLine($"ok: onTask saw {string.Join(",", terminalStates)}; no-A2A-profile 404s");
sealed class Stub : IDisposable{ readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<System.Net.HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}Parameters
Section titled “Parameters”| Parameter | Type | What it is |
|---|---|---|
addr |
string |
host:port to bind. port 0 (or omitted) picks a free ephemeral port. |
opts.Client |
LlmClient |
Fulfils each Task via RunAsync/AskAsync (contextId keyed, so a peer’s turns share a conversation). |
opts.A2A |
A2AConfig? |
Opt-in A2A profile — null (and no top-level a2a block) ⇒ no A2A routes mounted. |
opts.OnTask |
OnTask? |
Fires on each Task’s terminal state with the RunResult telemetry. |
opts.Mcp / opts.OnCall |
— | The independent MCP inbound profile — see McpServe.Build. |
See also
Section titled “See also”A2AServer.BuildAgentCard— Construct the Agent Card that advertises your name, skills and endpoint.A2AServer.FileTaskStore— Persist inbound A2A tasks so a suspended request survives a restart.McpServe.Build— The inbound MCP profile: any MCP client can call your tools.A2A.Agent— The outbound counterpart: call a served toolkit like this one from another agent.