Skip to content

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.

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.

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 { }
}
}
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.
  • 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.