diff --git a/extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs b/extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs
new file mode 100644
index 0000000..43a6121
--- /dev/null
+++ b/extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs
@@ -0,0 +1,63 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Volcanic.AgenticCore.Extensions.A2A;
+
+public sealed record RemoteAgentEntry(string Name, string Url, string? Key, string? Description);
+
+/// Skipped entries, described without keys.
+public sealed record AgentsFileResult(IReadOnlyList Agents, IReadOnlyList Problems, string? Error);
+
+///
+/// <workspace>/.volcanic/a2a/agents.json: remote agents the assistant may call. A plain file the agent and the
+/// user edit; read on every call (no cache).
+///
+public static partial class AgentsFile
+{
+ [GeneratedRegex("^[a-z0-9-]{1,40}$", RegexOptions.CultureInvariant)]
+ private static partial Regex NamePattern();
+
+ public static string PathFor(string workspace) => Path.Combine(workspace, ".volcanic", "a2a", "agents.json");
+
+ public static RemoteAgentEntry? Find(string workspace, string name) =>
+ Read(workspace).Agents.FirstOrDefault(a => a.Name == name);
+
+ public static AgentsFileResult Read(string workspace)
+ {
+ var path = PathFor(workspace);
+ if (!File.Exists(path)) return new([], [], null);
+ JsonElement root;
+ try
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(path));
+ root = doc.RootElement.Clone();
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
+ {
+ return new([], [], $".volcanic/a2a/agents.json could not be read: {ex.GetType().Name}.");
+ }
+ if (root.ValueKind != JsonValueKind.Array)
+ return new([], [], ".volcanic/a2a/agents.json must be a JSON array of agents.");
+
+ var agents = new List();
+ var problems = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+ var index = 0;
+ foreach (var item in root.EnumerateArray())
+ {
+ index++;
+ if (item.ValueKind != JsonValueKind.Object) { problems.Add($"entry {index} is not an object"); continue; }
+ var name = Str(item, "name");
+ var url = Str(item, "url");
+ if (name is null || !NamePattern().IsMatch(name)) { problems.Add($"entry {index}: name must match [a-z0-9-]{{1,40}}"); continue; }
+ if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
+ { problems.Add($"{name}: url must be an absolute http(s) URL"); continue; }
+ if (!seen.Add(name)) { problems.Add($"{name}: duplicate name"); continue; }
+ agents.Add(new RemoteAgentEntry(name, url, Str(item, "key"), Str(item, "description")));
+ }
+ return new(agents, problems, null);
+ }
+
+ private static string? Str(JsonElement e, string property) =>
+ e.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s ? s : null;
+}
diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs
new file mode 100644
index 0000000..4150125
--- /dev/null
+++ b/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs
@@ -0,0 +1,59 @@
+using Volcanic.AgenticCore.Extensions.A2A;
+
+namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
+
+public sealed class AgentsFileTests : IDisposable
+{
+ private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-agents", Guid.NewGuid().ToString("N"));
+
+ private void Write(string json)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(_ws))!);
+ File.WriteAllText(AgentsFile.PathFor(_ws), json);
+ }
+
+ [Fact]
+ public void Missing_NoAgentsNoError()
+ {
+ var r = AgentsFile.Read(_ws);
+ Assert.Empty(r.Agents);
+ Assert.Null(r.Error);
+ }
+
+ [Fact]
+ public void Agents_MalformedFile_ReportsFile()
+ {
+ Write("{ nope");
+ var r = AgentsFile.Read(_ws);
+ Assert.Empty(r.Agents);
+ Assert.Contains("agents.json", r.Error);
+ }
+
+ [Fact]
+ public void Read_ValidatesEntries_AndNeverEchoesKeys()
+ {
+ Write("""
+ [ { "name": "travel-bot", "url": "https://travel.example/", "key": "secret-1", "description": "Trips" },
+ { "name": "Bad Name", "url": "https://x.example/", "key": "secret-2" },
+ { "name": "no-url" },
+ { "name": "ftp", "url": "ftp://x.example/" },
+ { "name": "travel-bot", "url": "https://dup.example/" } ]
+ """);
+ var r = AgentsFile.Read(_ws);
+ var agent = Assert.Single(r.Agents);
+ Assert.Equal(new RemoteAgentEntry("travel-bot", "https://travel.example/", "secret-1", "Trips"), agent);
+ Assert.Equal(4, r.Problems.Count);
+ Assert.DoesNotContain(r.Problems, p => p.Contains("secret"));
+ }
+
+ [Fact]
+ public void Agents_FileEdited_AppliesToNextCall()
+ {
+ Write("""[ { "name": "a", "url": "https://a.example/" } ]""");
+ Assert.NotNull(AgentsFile.Find(_ws, "a"));
+ Write("[]");
+ Assert.Null(AgentsFile.Find(_ws, "a"));
+ }
+
+ public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
+}