diff --git a/CLAUDE.md b/CLAUDE.md
index afa952c..cc131b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,7 +23,7 @@ dotnet test SocialAgent.slnx --verbosity normal
## Architecture
-SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10. It uses the **Microsoft Agents Framework** (`Microsoft.Agents.Hosting.AspNetCore.A2A.Preview`) for A2A protocol support and runs as an ASP.NET Core application in Kubernetes.
+SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10. It hosts the **A2A 1.0** protocol via the **Microsoft Agent Framework** (`Microsoft.Agents.AI.Hosting.A2A.AspNetCore`) and the upstream **A2A .NET SDK** (`A2A`, `A2A.AspNetCore`), running as an ASP.NET Core application in Kubernetes.
**Core design principle:** Plugin-based provider architecture with normalized data models. Each social media platform is an independent provider assembly.
@@ -34,18 +34,20 @@ SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10
- **`src/SocialAgent.Analytics/`** — Analytics engine computing engagement summaries, top posts, follower insights, platform comparisons
- **`src/SocialAgent.Providers.Mastodon/`** — Mastodon REST API client implementing `ISocialMediaProvider`
- **`src/SocialAgent.Providers.Bluesky/`** — Bluesky AT Protocol client implementing `ISocialMediaProvider`
-- **`src/SocialAgent.Host/`** — ASP.NET Core host, A2A agent handler (`SocialAgentHandler`), background polling service
+- **`src/SocialAgent.Host/`** — ASP.NET Core host, A2A request handler (`SocialAgentA2AHandler` implementing `A2A.IAgentHandler`), skill dispatcher (`SkillDispatcher`), skill metadata (`SkillCatalog`), stub `AIAgent` (`SocialAgentStubAgent`, framework-required name carrier), background polling service
- **`tests/`** — MSTest unit tests with NSubstitute for mocking
- **`deploy/k8s/`** — Kubernetes manifests (Deployment, Service, ConfigMap, Secret)
### A2A Protocol
-The agent exposes the Google A2A protocol via the Microsoft Agents SDK:
-- **`GET /.well-known/agent.json`** — Agent card with skills (A2A spec)
-- **`GET /a2a/.well-known/agent-card.json`** — Agent card (alternate)
-- **`POST /a2a`** — JSON-RPC message handling
+The agent speaks **A2A protocol version 1.0**. Endpoints:
+- **`GET /.well-known/agent-card.json`** — Agent card (A2A 1.0 discovery path)
+- **`POST /a2a`** — JSON-RPC binding (methods: `SendMessage`, `GetTask`, etc., per `A2A.A2AMethods`)
+- **`POST /a2a/message:send`**, **`/a2a/tasks/{id}`**, etc. — HTTP+JSON binding routes
-Seven skills are exposed: `engagement-summary`, `top-posts`, `recent-mentions`, `follower-insights`, `platform-comparison`, `check-notifications`, `provider-status`.
+Both transports are mapped to the same `/a2a` prefix; clients pick whichever they prefer.
+
+Seven skills are exposed: `engagement-summary`, `top-posts`, `recent-mentions`, `follower-insights`, `platform-comparison`, `check-notifications`, `provider-status`. Skill dispatch is deterministic (keyword match, optionally LLM-assisted via `SkillRouter`) — there is no LLM in the request path by default. The `AIAgent` registered with the framework is a stub used only as a name carrier; all request handling flows through `SocialAgentA2AHandler`.
### Provider Plugin Pattern
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7d2c383..925a1f6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,8 +3,11 @@
true
-
-
+
+
+
+
+
@@ -20,11 +23,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/README.md b/README.md
index 2e1e120..72706c3 100644
--- a/README.md
+++ b/README.md
@@ -50,9 +50,11 @@ The agent starts on `http://localhost:5000` by default (or as configured by `ASP
### A2A Endpoints
-- `GET /.well-known/agent.json` — Agent card (A2A spec)
-- `GET /a2a/.well-known/agent-card.json` — Agent card (alternate)
-- `POST /a2a` — JSON-RPC task interface
+The agent speaks **A2A protocol version 1.0**.
+
+- `GET /.well-known/agent-card.json` — Agent card (A2A 1.0 discovery)
+- `POST /a2a` — JSON-RPC binding (`SendMessage`, `GetTask`, etc.)
+- `POST /a2a/message:send`, `GET /a2a/tasks/{id}`, etc. — HTTP+JSON binding
- `GET /health/ready` — Readiness probe
- `GET /health/live` — Liveness probe
@@ -98,10 +100,10 @@ The agent runs as a continuous Deployment (not CronJob) for A2A responsiveness.
│ SocialAgent.Host (ASP.NET Core) │
│ │
│ ┌──────────────┐ ┌─────────────────────────────────┐ │
-│ │ A2A Endpoints │ │ Background Polling Services │ │
-│ │ (MS Agents │ │ ┌───────────┐ ┌──────────────┐ │ │
-│ │ Framework) │ │ │ Mastodon │ │ Bluesky │ │ │
-│ │ │ │ │ Provider │ │ Provider │ │ │
+│ │ A2A 1.0 │ │ Background Polling Services │ │
+│ │ (MS Agent │ │ ┌───────────┐ ┌──────────────┐ │ │
+│ │ Framework + │ │ │ Mastodon │ │ Bluesky │ │ │
+│ │ A2A SDK) │ │ │ Provider │ │ Provider │ │ │
│ └──────┬───────┘ │ └─────┬─────┘ └──────┬───────┘ │ │
│ │ └───────┼───────────────┼─────────┘ │
│ ┌──────▼───────────────────▼───────────────▼─────────┐ │
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml
index d3bd0fd..aeeb95a 100644
--- a/deploy/k8s/configmap.yaml
+++ b/deploy/k8s/configmap.yaml
@@ -8,3 +8,4 @@ data:
mastodon-instance-url: "https://fosstodon.org"
bluesky-enabled: "true"
bluesky-handle: "rocky.lhotka.net"
+ public-base-url: "http://social-agent.rockbot.svc.cluster.local"
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml
index 1e73f2b..6b5d9cb 100644
--- a/deploy/k8s/deployment.yaml
+++ b/deploy/k8s/deployment.yaml
@@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: social-agent
- image: rockylhotka/socialagent:latest
+ image: rockylhotka/socialagent:1.3.4
ports:
- containerPort: 8080
env:
@@ -27,6 +27,11 @@ spec:
value: "http://+:8080"
- name: SocialAgent__DatabaseProvider
value: "PostgreSQL"
+ - name: SocialAgent__PublicBaseUrl
+ valueFrom:
+ configMapKeyRef:
+ name: social-agent-config
+ key: public-base-url
- name: ConnectionStrings__SocialAgent
valueFrom:
secretKeyRef:
diff --git a/src/SocialAgent.Host/Program.cs b/src/SocialAgent.Host/Program.cs
index 81b8974..c2c1e66 100644
--- a/src/SocialAgent.Host/Program.cs
+++ b/src/SocialAgent.Host/Program.cs
@@ -1,8 +1,7 @@
-using Microsoft.Agents.Builder;
-using Microsoft.Agents.Builder.State;
-using Microsoft.Agents.Hosting.A2A;
-using Microsoft.Agents.Hosting.AspNetCore;
-using Microsoft.Agents.Storage;
+using System.Reflection;
+using A2A;
+using A2A.AspNetCore;
+using Microsoft.AspNetCore.Authorization;
using Serilog;
using SocialAgent.Analytics;
using SocialAgent.Data;
@@ -22,15 +21,14 @@
builder.Host.UseSerilog((context, configuration) => configuration
.ReadFrom.Configuration(context.Configuration));
-// Agent infrastructure
-builder.Services.AddSingleton();
-builder.AddAgentApplicationOptions();
-
-// Register the agent
-builder.AddAgent();
-
-// Register A2A adapter
-builder.Services.AddA2AAdapter();
+// Register the stub AIAgent and the custom A2A handler under the same agent name.
+// The framework's AddA2AServer wires its A2AServer keyed by this name; because we register a
+// keyed IAgentHandler here, the framework's default A2AAgentHandler (which would call into the
+// AIAgent) is bypassed entirely.
+builder.Services.AddKeyedSingleton(SocialAgentStubAgent.AgentName);
+builder.Services.AddSingleton();
+builder.Services.AddKeyedSingleton(SocialAgentStubAgent.AgentName);
+builder.AddA2AServer(SocialAgentStubAgent.AgentName);
// Data layer
builder.Services.AddSocialAgentData(builder.Configuration);
@@ -76,11 +74,50 @@
app.UseAuthentication();
app.UseAuthorization();
-// Map A2A endpoints (no auth required for development)
-app.MapA2AEndpoints(requireAuth: !app.Environment.IsDevelopment());
+// Build the agent card. The A2A v1.0 spec example shows interface URLs as absolute URLs, so
+// when SocialAgent:PublicBaseUrl is configured we emit absolute URLs there. In dev, where the
+// public base is unknown, we fall back to the relative path "/a2a".
+var agentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0";
+var publicBaseUrl = builder.Configuration["SocialAgent:PublicBaseUrl"]?.TrimEnd('/');
+var a2aInterfaceUrl = string.IsNullOrEmpty(publicBaseUrl) ? "/a2a" : $"{publicBaseUrl}/a2a";
+var agentCard = new AgentCard
+{
+ Name = "SocialAgent",
+ Description = "Social media monitoring and analytics agent. " +
+ "Monitors Mastodon, Bluesky, and other platforms for posts, mentions, and engagement. " +
+ "Provides analytics on engagement trends, top posts, and follower insights.",
+ Version = agentVersion,
+ Skills = [.. SkillCatalog.AgentCardSkills],
+ DefaultInputModes = ["text"],
+ DefaultOutputModes = ["text"],
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = a2aInterfaceUrl, ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" },
+ new AgentInterface { Url = a2aInterfaceUrl, ProtocolBinding = "HTTPJSON", ProtocolVersion = "1.0" }
+ ]
+};
+
+// Map A2A endpoints. Both transports share the /a2a path — JSON-RPC handles POST /a2a, HTTP+JSON
+// handles the spec routes (e.g., /a2a/tasks/{id}, /a2a/message:send).
+var requireAuth = !app.Environment.IsDevelopment();
+var jsonRpcEndpoints = app.MapA2AJsonRpc(SocialAgentStubAgent.AgentName, "/a2a");
+var httpJsonEndpoints = app.MapA2AHttpJson(SocialAgentStubAgent.AgentName, "/a2a");
+var agentCardEndpoints = app.MapWellKnownAgentCard(agentCard);
+
+if (requireAuth)
+{
+ var policy = new AuthorizationPolicyBuilder(ApiKeyAuthenticationHandler.SchemeName)
+ .RequireAuthenticatedUser()
+ .Build();
+ jsonRpcEndpoints.RequireAuthorization(policy);
+ httpJsonEndpoints.RequireAuthorization(policy);
+ // Agent card stays anonymous so clients can discover capabilities without credentials.
+}
-// Health check endpoints
+// Health check endpoints (anonymous)
app.MapHealthChecks("/health/ready");
app.MapHealthChecks("/health/live");
app.Run();
+
+public partial class Program { }
diff --git a/src/SocialAgent.Host/SkillCatalog.cs b/src/SocialAgent.Host/SkillCatalog.cs
new file mode 100644
index 0000000..574af26
--- /dev/null
+++ b/src/SocialAgent.Host/SkillCatalog.cs
@@ -0,0 +1,127 @@
+using A2A;
+using SocialAgent.Host.Routing;
+
+namespace SocialAgent.Host;
+
+internal static class SkillCatalog
+{
+ // Skill parameters arrive as flat keys on message.metadata, alongside metadata.skill. Parameter
+ // keys are documented per-skill below. Unknown keys are ignored. Parameter values may be JSON
+ // strings, numbers, or ISO-8601 timestamps as appropriate; the dispatcher coerces strings to
+ // numbers/dates where needed.
+ public static IReadOnlyList AgentCardSkills { get; } =
+ [
+ new()
+ {
+ Id = "engagement-summary",
+ Name = "Engagement Summary",
+ Description = "Get a summary of recent engagement. Returns total likes, reposts, replies, mentions, new followers, and averages per post. " +
+ "Optional metadata parameters: providerId (string, e.g. \"mastodon\" or \"bluesky\"; default: union across all providers), since (ISO-8601 timestamp; default: last 7 days).",
+ Tags = ["social", "analytics", "engagement"]
+ },
+ new()
+ {
+ Id = "top-posts",
+ Name = "Top Posts",
+ Description = "Get your most-engaged posts ranked by total engagement (likes + reposts + replies). " +
+ "Optional metadata parameters: providerId (string), count (integer, default 10), since (ISO-8601 timestamp).",
+ Tags = ["social", "analytics", "posts"]
+ },
+ new()
+ {
+ Id = "recent-mentions",
+ Name = "Recent Mentions",
+ Description = "Get recent mentions and replies. " +
+ "Optional metadata parameters: providerId (string; default: union), count (integer, default 20).",
+ Tags = ["social", "mentions", "notifications"]
+ },
+ new()
+ {
+ Id = "follower-insights",
+ Name = "Follower Insights",
+ Description = "See who engages most with your content and their interaction patterns. " +
+ "Optional metadata parameters: providerId (string), count (integer, default 10), since (ISO-8601 timestamp).",
+ Tags = ["social", "analytics", "followers"]
+ },
+ new()
+ {
+ Id = "platform-comparison",
+ Name = "Platform Comparison",
+ Description = "Compare engagement metrics across all connected social media platforms. " +
+ "Optional metadata parameters: since (ISO-8601 timestamp; default: last 7 days). This skill is always all-platforms by design.",
+ Tags = ["social", "analytics", "comparison"]
+ },
+ new()
+ {
+ Id = "check-notifications",
+ Name = "Check Notifications",
+ Description = "Get unread notifications. " +
+ "Optional metadata parameters: providerId (string; default: union).",
+ Tags = ["social", "notifications"]
+ },
+ new()
+ {
+ Id = "provider-status",
+ Name = "Provider Status",
+ Description = "Check connectivity and health of all configured social media providers. Takes no parameters.",
+ Tags = ["social", "status", "health"]
+ }
+ ];
+
+ public static IReadOnlyList RouterDefinitions { get; } =
+ [
+ new("engagement-summary", "Engagement Summary", "Get a summary of recent engagement across all connected social media platforms"),
+ new("top-posts", "Top Posts", "Get most-engaged posts ranked by total engagement"),
+ new("recent-mentions", "Recent Mentions", "Get recent mentions and replies across all connected platforms"),
+ new("follower-insights", "Follower Insights", "See who engages most with your content"),
+ new("platform-comparison", "Platform Comparison", "Compare engagement metrics across all connected platforms"),
+ new("check-notifications", "Check Notifications", "Get unread notifications across all connected platforms"),
+ new("provider-status", "Provider Status", "Check connectivity and health of all configured providers")
+ ];
+
+ private static readonly Dictionary SkillKeywords = new()
+ {
+ ["engagement-summary"] = ["engagement summary", "engagement-summary"],
+ ["top-posts"] = ["top posts", "top-posts", "most engaged", "best posts"],
+ ["recent-mentions"] = ["recent mentions", "recent-mentions", "mentions"],
+ ["follower-insights"] = ["follower insights", "follower-insights", "followers", "engagers"],
+ ["platform-comparison"] = ["platform comparison", "platform-comparison", "compare platforms", "compare engagement"],
+ ["check-notifications"] = ["check notifications", "check-notifications", "notifications", "unread"],
+ ["provider-status"] = ["provider status", "provider-status", "connectivity", "health check"]
+ };
+
+ private static readonly HashSet KnownSkillIds = new(StringComparer.Ordinal)
+ {
+ "engagement-summary",
+ "top-posts",
+ "recent-mentions",
+ "follower-insights",
+ "platform-comparison",
+ "check-notifications",
+ "provider-status"
+ };
+
+ public static bool IsKnownSkill(string id) => KnownSkillIds.Contains(id);
+
+ public static string MatchSkillByKeywords(string text)
+ {
+ var lower = text.ToLowerInvariant();
+
+ foreach (var skill in SkillKeywords)
+ {
+ if (lower == skill.Key)
+ return skill.Key;
+ }
+
+ foreach (var skill in SkillKeywords)
+ {
+ foreach (var keyword in skill.Value)
+ {
+ if (lower.Contains(keyword))
+ return skill.Key;
+ }
+ }
+
+ return lower;
+ }
+}
diff --git a/src/SocialAgent.Host/SkillDispatcher.cs b/src/SocialAgent.Host/SkillDispatcher.cs
new file mode 100644
index 0000000..30f30d2
--- /dev/null
+++ b/src/SocialAgent.Host/SkillDispatcher.cs
@@ -0,0 +1,163 @@
+using System.Globalization;
+using System.Text.Json;
+using SocialAgent.Core.Analytics;
+using SocialAgent.Core.Providers;
+using SocialAgent.Data.Repositories;
+using SocialAgent.Host.Routing;
+
+namespace SocialAgent.Host;
+
+internal sealed class SkillDispatcher(IServiceScopeFactory scopeFactory, ILogger logger)
+{
+ private static readonly JsonSerializerOptions JsonPretty = new() { WriteIndented = true };
+
+ public async Task DispatchAsync(
+ string? explicitSkillId,
+ string userText,
+ IReadOnlyDictionary? parameters,
+ CancellationToken ct)
+ {
+ var text = userText?.Trim() ?? string.Empty;
+
+ using var scope = scopeFactory.CreateScope();
+
+ string skillId;
+ var parameterSummary = SummarizeParameters(parameters);
+ if (!string.IsNullOrWhiteSpace(explicitSkillId) && SkillCatalog.IsKnownSkill(explicitSkillId))
+ {
+ skillId = explicitSkillId;
+ logger.LogInformation("Dispatching skill {SkillId} (from request metadata) parameters={Parameters}", skillId, parameterSummary);
+ }
+ else
+ {
+ var router = scope.ServiceProvider.GetService();
+ skillId = router != null
+ ? await router.RouteAsync(text, SkillCatalog.RouterDefinitions, ct) ?? SkillCatalog.MatchSkillByKeywords(text)
+ : SkillCatalog.MatchSkillByKeywords(text);
+ logger.LogInformation("Dispatching skill {SkillId} (routed from text \"{Input}\") parameters={Parameters}", skillId, text, parameterSummary);
+ }
+
+ var p = new SkillParameters(parameters);
+
+ return skillId switch
+ {
+ "engagement-summary" => await HandleEngagementSummaryAsync(scope.ServiceProvider, p, ct),
+ "top-posts" => await HandleTopPostsAsync(scope.ServiceProvider, p, ct),
+ "recent-mentions" => await HandleRecentMentionsAsync(scope.ServiceProvider, p, ct),
+ "follower-insights" => await HandleFollowerInsightsAsync(scope.ServiceProvider, p, ct),
+ "platform-comparison" => await HandlePlatformComparisonAsync(scope.ServiceProvider, p, ct),
+ "check-notifications" => await HandleCheckNotificationsAsync(scope.ServiceProvider, p, ct),
+ "provider-status" => await HandleProviderStatusAsync(scope.ServiceProvider, ct),
+ _ => $"Unknown skill '{skillId}'. Available skills: engagement-summary, top-posts, recent-mentions, follower-insights, platform-comparison, check-notifications, provider-status"
+ };
+ }
+
+ private static async Task HandleEngagementSummaryAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct)
+ {
+ var analytics = sp.GetRequiredService();
+ var summary = await analytics.GetEngagementSummaryAsync(p.ProviderId, p.Since, ct);
+ return FormatJson(summary);
+ }
+
+ private static async Task HandleTopPostsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct)
+ {
+ var analytics = sp.GetRequiredService();
+ var posts = await analytics.GetTopPostsAsync(p.Count ?? 10, p.ProviderId, p.Since, ct);
+ return FormatJson(posts);
+ }
+
+ private static async Task HandleRecentMentionsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct)
+ {
+ var analytics = sp.GetRequiredService();
+ var mentions = await analytics.GetRecentMentionsAsync(p.Count ?? 20, p.ProviderId, ct);
+ return FormatJson(mentions);
+ }
+
+ private static async Task HandleFollowerInsightsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct)
+ {
+ var analytics = sp.GetRequiredService();
+ var engagers = await analytics.GetTopEngagersAsync(p.Count ?? 10, p.ProviderId, p.Since, ct);
+ return FormatJson(engagers);
+ }
+
+ private static async Task HandlePlatformComparisonAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct)
+ {
+ var analytics = sp.GetRequiredService();
+ var comparison = await analytics.GetPlatformComparisonAsync(p.Since, ct);
+ return FormatJson(comparison);
+ }
+
+ private static async Task HandleCheckNotificationsAsync(IServiceProvider sp, SkillParameters p, CancellationToken ct)
+ {
+ var repository = sp.GetRequiredService();
+ var unread = await repository.GetUnreadNotificationsAsync(p.ProviderId, ct);
+ return FormatJson(unread);
+ }
+
+ private static async Task HandleProviderStatusAsync(IServiceProvider sp, CancellationToken ct)
+ {
+ var providers = sp.GetServices();
+ var statuses = new List