A2A.Agent
C# · package Toolnexus · SPEC §7A · A2A.cs
public sealed class Agent{ public string Card { get; set; } = ""; public IDictionary<string, string>? Headers { get; set; } public long? Timeout { get; set; } // ms, default 300000 public long? PollEvery { get; set; } // ms, default 1000}A descriptor pointing at a remote peer’s Agent Card URL — not a factory function. Build one with
an object initializer (new Agent { Card = url }) and hand it to
A2A.AgentTools directly, or drop it into
Toolkit.Options.Agents/Toolkit.AddAgentAsync to have the toolkit resolve, isolate, and register
its skills for you.
When to use it
Section titled “When to use it”You have (or are given) the URL of another A2A agent’s card — your own, a teammate’s toolkit, or
any third-party A2A peer — and want its skills to show up in your model’s tool list exactly like a
native or MCP tool. Card is the only required field; Headers, Timeout, and PollEvery tune
auth and the submit→poll budget per peer.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — register a descriptor on a toolkit
Section titled “1. The smallest useful call — register a descriptor on a toolkit”using Toolnexus;
// --- the peer: a toolkit exposing one skill, served as an A2A agent ---using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """{"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""));
await using var peer = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Skills = new List<SkillSource.SkillDef> { new("greet", "says hello", "Say a friendly hello.") },});var peerClient = LlmClient.Create(new LlmClient.Options{ BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",});var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions{ Client = peerClient, A2A = new A2AConfig { Name = "desk" },});
// --- the caller: a plain descriptor, registered on ITS OWN toolkit ---await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Agents = new List<Agent> { new() { Card = handle.Url + "/.well-known/agent-card.json" } },});
var names = tk.Tools().Select(t => t.Name).ToList();if (names.Count != 1 || names[0] != "desk_greet") throw new Exception(string.Join(",", names));
await handle.StopAsync();Console.WriteLine($"ok: {string.Join(",", names)}");
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 — call the tool, round-trip through the real peer
Section titled “2. The realistic case — call the tool, round-trip through the real peer”using Toolnexus;
using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """{"choices":[{"message":{"content":"hello, friend"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""));
await using var peer = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Skills = new List<SkillSource.SkillDef> { new("greet", "says hello", "Say a friendly hello.") },});var peerClient = LlmClient.Create(new LlmClient.Options{ BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",});var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions{ Client = peerClient, A2A = new A2AConfig { Name = "desk" },});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Agents = new List<Agent> { new() { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25 }, },});
var tool = tk.Get("desk_greet") ?? throw new Exception("tool not registered");var result = await tool.ExecuteAsync(new Dictionary<string, object?> { ["task"] = "say hi" });if (result.IsError) throw new Exception(result.Output);if (result.Output != "hello, friend") 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 — Timeout/PollEvery tuning, and a bad peer is isolated, not fatal
Section titled “3. Full surface — Timeout/PollEvery tuning, and a bad peer is isolated, not fatal”using Toolnexus;
using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """{"choices":[{"message":{"content":"pong"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""));
await using var peer = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Skills = new List<SkillSource.SkillDef> { new("ping", "says pong", "Reply pong.") },});var peerClient = LlmClient.Create(new LlmClient.Options{ BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k",});var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions{ Client = peerClient, A2A = new A2AConfig { Name = "reachable" },});
// A reachable peer with a tuned poll budget, alongside one whose card can never be fetched —// registered together the way a real app would declare its `agents` config.await using var tk = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = false, Agents = new List<Agent> { new() { Card = handle.Url + "/.well-known/agent-card.json", Timeout = 5_000, PollEvery = 25 }, new() { Card = "http://127.0.0.1:1/.well-known/agent-card.json" }, // nothing listens on :1 },});
// The bad agent is isolated: no exception, its skills just never appear.var names = tk.Tools().Select(t => t.Name).ToList();if (names.Count != 1 || names[0] != "reachable_ping") throw new Exception(string.Join(",", names));
var result = await tk.ExecuteAsync("reachable_ping", new Dictionary<string, object?> { ["task"] = "ping" });if (result.IsError || result.Output != "pong") throw new Exception(result.Output);
await handle.StopAsync();Console.WriteLine($"ok: {string.Join(",", names)} -> {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 { } }}Fields
Section titled “Fields”| Field | Type | What it is |
|---|---|---|
Card |
string |
The Agent Card URL (GET). Required. |
Headers |
IDictionary<string, string>? |
Sent on both the card GET and every JSON-RPC POST. ${ENV_VAR} values are expanded at call time and never logged. |
Timeout |
long? |
Overall poll budget in ms. Default 300000. |
PollEvery |
long? |
Interval between GetTask polls in ms. Default 1000. |
See also
Section titled “See also”A2A.AgentTools— Expand a remote agent card into one tool per advertised skill.A2A.ParseAgentsConfig— Declare remote peers in config the way MCP servers are declared, with precedence rules.A2AServer.Start— The inbound counterpart: serve your own toolkit as the peer being called here.