Date: 2026-04-06
Branch: hal/agent-communication (from compound/daemon-local-runtime-foundation)
Spec refs: CLI §7.6, §7.7, §19.1, SPEC §02 §10, §05 §3.2, §10 §2-5, §11 §3
Issues: New — "Agent communication and lifecycle MVP"
Priority: HIGH — Agent-to-agent interaction is the next major functional gap
Estimated: ~900 LoC net new across 14 files
Coverage lift: Completes CLI §7.6 + §7.7; advances SPEC §02 §10
Before diving into implementation, we conducted a deep technical survey of agent communication protocols (A2A, MCP, WebSocket, NATS, gRPC, SSE, REST) to determine the optimal approach for AWN. Key findings:
| Protocol | AWN Fit | Rationale |
|---|---|---|
| SSE + HTTP POST (current) | ⭐⭐⭐⭐ for MVP | Already implemented; sufficient for turn-based (≥1s intervals); standard HTTP infra |
| A2A Agent Card | ⭐⭐⭐⭐⭐ for discovery | Industry standard (/.well-known/agent.json); adopted by Google ADK, CrewAI; low implementation cost |
| A2A tasks/send | ⭐⭐ for game worlds | Optimized for delegation, not participation; task lifecycle adds overhead to game turns |
| WebSocket | ⭐⭐⭐⭐⭐ for real-time | Upgrade path for market-trade world when sub-10ms matters; not MVP critical |
| NATS | ⭐⭐⭐⭐⭐ for fan-out | Best for spectator broadcast + order matching; adds infrastructure dependency |
| MCP | ⭐⭐⭐ for tool exposure | World-as-MCP-server is elegant for LLM agents; separate concern from messaging |
| gRPC | ⭐⭐⭐ for typed RPC | Excellent perf; requires proto toolchain; overkill for MVP |
┌─────────────────────────────────────────────────────────┐
│ Layer 4: MCP compat (future) — World as MCP Server │
├─────────────────────────────────────────────────────────┤
│ Layer 3: A2A federation (future) — tasks/send │
├─────────────────────────────────────────────────────────┤
│ Layer 2: A2A discovery (THIS PR) — Agent Card endpoint │
├─────────────────────────────────────────────────────────┤
│ Layer 1: AWN Wire Protocol (THIS PR extends) │
│ SSE push + HTTP POST actions + message relay endpoints │
│ + webhook delivery + agent lifecycle │
└─────────────────────────────────────────────────────────┘
Key design decisions:
- World-relayed messaging (not P2P) — simplest, most secure; world enforces rate limits, policies, audit
- A2A Agent Card for discovery — zero-cost interop with emerging ecosystem
- Keep SSE + POST as core wire protocol — adequate for all 5 world types at current scale
- Webhook delivery per spec §02 §10 — enables serverless/cloud agents
- In-memory inbox for MVP — messages aren't critical-path data; SQLite persistence later
- No NATS/WebSocket/gRPC yet — upgrade paths documented, not MVP requirements
With PR #38 closing trust chain gaps, the largest remaining functional gap is agent-to-agent communication. Currently:
- Agents can join worlds, take actions, and receive SSE events — but cannot discover peers, cannot exchange messages, and cannot control online/offline presence
- 4 CLI commands (
awn agents,awn ping,awn send,awn messages) return "not implemented yet" - 3 lifecycle commands (
awn agent status/offline/online) have enum variants but no logic - Spec §02 §10 defines 4 event delivery modes — only SSE exists
- Gateway announcements reference
agentCardUrlbut no endpoint serves it
| Component | File:Line | What It Provides |
|---|---|---|
AgentRegistry |
src/world_api/server.rs:313-394 |
In-memory agent tracking: register(), remove(), get(), agent_ids() |
AgentInfo struct |
src/world_api/server.rs:296-306 |
Per-agent: awn_id, public_key, subject_id, slot |
SessionInfo struct |
src/world_api/endpoints.rs:266-278 |
Per-session: session_id, slot, joined_at, active |
EventBus |
src/world_api/sse.rs:114-131 |
broadcast::Sender<EventEntry> + replay buffer; send_to(agent_id), broadcast() |
EventEntry.target_agent |
src/world_api/sse.rs |
Option<String> — filters events to specific agent in SSE stream |
subscribe() |
src/world_api/sse.rs:215-259 |
Returns filtered stream per agent (replay + live) |
| Deferred CLI commands | src/cli/commands.rs:134-137 |
Agents, Ping, Send, Messages — all route to deferred_output() |
AgentSubcommand |
src/cli/commands.rs:718-726 |
Empty enum — Status, Offline, Online not yet defined |
WorldManifest |
src/schema/mod.rs:66-83 |
world, actions, protocol, state_schema, events, trust |
WorldAnnouncement |
src/gateway/types.rs:19 |
world_id, name, slug, category, url, version |
Registration File Service |
src/identity/registration_file.rs:12-20 |
name: "A2A", endpoint, version — A2A service reference exists |
| World API routes | src/world_api/server.rs:1024-1036 |
11 endpoints; no /v0/agents, /v0/messages, /v0/ping, or /.well-known |
| SSE endpoint | src/world_api/server.rs:1032 |
GET /v0/events — functional, signed events |
| SSE client (daemon) | src/daemon/sse_client.rs |
Connects to world SSE, verifies signatures, ring buffer |
Goal: Let joined agents see who else is in the world — foundation for messaging and coordination.
Spec: CLI §7.6 — awn agents "inspect peers"
Current State: AgentRegistry stores AgentInfo per joined agent. agent_ids() returns all AWN IDs. No HTTP endpoint exposes this.
Step 1: Add response types in endpoints.rs:
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSummary {
pub awn_id: String,
pub subject_id: Option<String>,
pub slot: u32,
pub joined_at: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentsResponse {
pub agents: Vec<AgentSummary>,
pub count: usize,
}Step 2: Add agents_handler in endpoints.rs:
pub async fn agents_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
) -> Result<Json<ApiResponse<AgentsResponse>>, ApiError> {
// 1. Verify caller is joined (sessions.contains_key)
// 2. Read from registry + sessions
// 3. Build AgentSummary for each agent
// 4. Return in standard envelope
}Step 3: Register route:
.route("/v0/agents", get(agents_handler))Step 4: Implement awn agents CLI — accepts optional --world <slug> flag. Calls GET /v0/agents on the world endpoint. Displays in TOON format:
Agents (3)
aw:sha256:abc123 slot=0 joined 2m ago
aw:sha256:def456 slot=1 joined 5m ago
aw:sha256:ghi789 slot=2 joined 1m ago
Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/cli/commands.rs
AC:
-
GET /v0/agentsreturns list of all joined agents - Each agent includes: awn_id, subject_id (if present), slot, joined_at
- Auth required — only joined agents can call (returns
NOT_JOINEDotherwise) - Rate limit: 120/min per agent (same as
GET /v0/state) -
awn agents --world <slug>CLI renders list - Test: 3 agents joined → list returns all 3
- Test: non-joined caller → 401
NOT_JOINED - Test: agent leaves → no longer in list
Goal: Enable agents in the same world to send direct messages via the world as relay.
Spec: CLI §7.6 — awn send <agent-id> <message> and awn messages
Design rationale: World-relayed (not P2P) because:
- No need for agents to expose endpoints to each other
- World enforces rate limits, content policies, audit trail
- Messages delivered via existing SSE infrastructure (
send_to) - Simplest path to MVP; P2P via A2A is an upgrade path
Step 1: Define message types:
// src/world_api/messages.rs (new file)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendMessageRequest {
pub to: String, // Recipient awn_id
pub content: serde_json::Value, // JSON payload
pub content_type: Option<String>, // MIME hint, default "application/json"
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AgentMessage {
pub message_id: String, // UUIDv7
pub from: String, // Sender awn_id
pub to: String, // Recipient awn_id
pub content: serde_json::Value,
pub content_type: String,
pub sent_at: String, // ISO 8601
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesResponse {
pub messages: Vec<AgentMessage>,
pub count: usize,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesQuery {
pub peek: Option<bool>, // true = don't consume
pub since: Option<String>, // ISO 8601 filter
pub limit: Option<u32>, // max messages, default 50
}
/// Per-agent bounded inbox.
pub const MAX_INBOX_SIZE: usize = 100;Step 2: Add message inbox to EndpointState:
// In EndpointState
/// Per-agent message inbox. Bounded at MAX_INBOX_SIZE per agent.
/// Oldest messages dropped on overflow.
pub message_inbox: RwLock<HashMap<String, VecDeque<AgentMessage>>>,Step 3: Implement send_message_handler:
pub async fn send_message_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
Json(req): Json<SendMessageRequest>,
) -> Result<Json<ApiResponse<AgentMessage>>, ApiError> {
// 1. Verify sender is joined
let sessions = state.sessions.read().await;
if !sessions.contains_key(&agent.awn_id) {
return Err(ApiError::new(ApiErrorCode::NotJoined, "must be joined to send messages"));
}
// 2. Verify recipient is joined
if !sessions.contains_key(&req.to) {
return Err(ApiError::new(ApiErrorCode::NotJoined,
format!("recipient {} is not joined", req.to)));
}
drop(sessions);
// 3. Create message
let msg = AgentMessage {
message_id: format!("msg_{}", uuid::Uuid::now_v7()),
from: agent.awn_id.clone(),
to: req.to.clone(),
content: req.content,
content_type: req.content_type.unwrap_or_else(|| "application/json".to_string()),
sent_at: chrono::Utc::now().to_rfc3339(),
};
// 4. Push to recipient inbox (drop oldest on overflow)
{
let mut inboxes = state.message_inbox.write().await;
let inbox = inboxes.entry(req.to.clone()).or_insert_with(VecDeque::new);
if inbox.len() >= MAX_INBOX_SIZE {
inbox.pop_front(); // Drop oldest
}
inbox.push_back(msg.clone());
}
// 5. Push SSE notification to recipient
state.event_bus.send_to(
&req.to,
"world.message",
"message.received",
serde_json::json!({
"messageId": msg.message_id,
"from": msg.from,
"contentType": msg.content_type,
}),
);
Ok(Json(ApiResponse::ok(msg)))
}Step 4: Implement get_messages_handler:
pub async fn get_messages_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
Query(params): Query<MessagesQuery>,
) -> Result<Json<ApiResponse<MessagesResponse>>, ApiError> {
// 1. Verify caller is joined
// 2. Read messages from inbox
// - If peek=true: clone without draining
// - If peek=false (default): drain inbox (consume-on-read)
// 3. Apply ?since filter and ?limit
// 4. Return messages
}Step 5: Clean up inbox on agent leave — in leave_handler() and evict_agent():
// After removing agent from sessions/registry:
state.message_inbox.write().await.remove(&agent.awn_id);Step 6: Register routes:
.route("/v0/messages", post(send_message_handler).get(get_messages_handler))Step 7: Wire CLI commands:
// awn send --world <slug> --to <agent-id> --message <json_or_text>
// awn messages --world <slug> [--peek] [--limit N]Files: src/world_api/messages.rs (new), src/world_api/endpoints.rs, src/world_api/server.rs, src/world_api/mod.rs, src/cli/commands.rs
AC:
-
POST /v0/messagesstores message in recipient inbox + pushes SSEmessage.received -
GET /v0/messagesdrains inbox (consume-on-read);?peek=truereads without consuming - Both sender and recipient must be joined
- Message IDs are UUIDv7 (
msg_prefix) - Per-agent inbox capped at 100; oldest dropped on overflow
- Messages cleaned up on leave/eviction
- Rate limit: POST 30/min, GET 60/min per agent
-
awn sendandawn messagesCLI commands work - Test: A→B message → B inbox has it → B reads → inbox empty
- Test: SSE
message.receivedevent pushed to B - Test: send to non-joined → error
- Test: inbox overflow (101 messages) → oldest dropped, newest kept
- Test: agent leaves → inbox cleared
Goal: Let agents check status and toggle online/offline presence.
Spec: CLI §7.7 and §19.1:
| Command | Gateway | Local Daemon | Joined Worlds |
|--------------------|--------------|--------------|---------------------|
| awn agent offline | Unannounced | Running | Connections paused |
| awn agent online | Announced | Running | Connections resumed |
| awn daemon stop | Unannounced | Stopped | Disconnected |
Current State: AgentSubcommand enum in commands.rs:726 is empty. No Status/Offline/Online variants.
Step 1: Define subcommands:
// src/cli/commands.rs
#[derive(Debug, Subcommand)]
pub enum AgentSubcommand {
/// Show agent connectivity status.
Status,
/// Unannounce from gateway, pause connections.
Offline,
/// Re-announce to gateway, resume connections.
Online,
/// Register a webhook handler for a world.
SetHandler {
/// World slug.
world: String,
/// Webhook URL for event delivery.
#[arg(long)]
webhook: Option<String>,
/// Remove existing handler.
#[arg(long)]
remove: bool,
},
}Step 2: Implement awn agent status:
Read daemon PID file + identity state → display:
Agent
ID: aw:sha256:abc123...
Status: online
Daemon: running (PID 12345)
Uptime: 2h 15m
Worlds
Joined: 0
Uses existing daemon_status() from src/daemon/mod.rs and identity loading from src/identity/state.rs.
Step 3: Implement online/offline via state file:
// src/daemon/mod.rs — add functions
const AGENT_ONLINE_MARKER: &str = "agent-online";
pub fn set_agent_online(dirs: &AppDirs, online: bool) -> std::io::Result<()> {
let marker = dirs.state_dir().join(AGENT_ONLINE_MARKER);
if online {
std::fs::write(&marker, "1")
} else {
match std::fs::remove_file(&marker) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
pub fn is_agent_online(dirs: &AppDirs) -> bool {
dirs.state_dir().join(AGENT_ONLINE_MARKER).exists()
}Step 4: In the gateway announcer (wherever heartbeat is sent), check online marker:
// Skip heartbeat if offline
if !is_agent_online(&self.dirs) {
continue;
}Files: src/cli/commands.rs, src/daemon/mod.rs
AC:
-
awn agent statusshows: ID, status (online/offline/daemon-stopped), daemon PID, uptime -
awn agent offlineremoves online marker → announcer pauses heartbeat -
awn agent onlinewrites online marker → announcer resumes - Offline persists across daemon restart (file-based)
- Joined worlds remain joined during offline (not disconnected)
- Test:
statusshows correct state - Test:
offline→ heartbeat paused - Test:
online→ heartbeat resumed
Goal: Enable agents to register a webhook URL for push-style event delivery.
Spec: §02 §10 — "Webhook: HTTP POST to registered URL. Agent responds with action in HTTP response body."
Webhook format per spec:
POST https://my-agent.example.com/events
Content-Type: application/json
X-AWN-World-Id: aw:sha256:world789...
X-AWN-Event-Id: evt_019d3a0f_001
{
"worldId": "aw:sha256:world789...",
"event": "turn.started",
"payload": { ... },
"timestamp": "2026-03-30T10:00:00Z"
}
→ Agent can respond with:
{
"action": "bet",
"params": { "amount": 10 }
}
Current State: Only SSE delivery exists. No webhook registration or delivery.
Step 1: Create webhook config persistence:
// src/daemon/webhook.rs (new file)
use std::collections::HashMap;
use std::path::Path;
#[derive(Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebhookConfig {
/// Map of world_slug → webhook handler.
pub handlers: HashMap<String, WebhookHandler>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WebhookHandler {
pub url: String,
pub world_slug: String,
pub registered_at: String,
}
impl WebhookConfig {
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(self)?;
// Atomic write: temp file → fsync → rename
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, path)
}
pub fn register(&mut self, world_slug: &str, url: &str) {
self.handlers.insert(world_slug.to_string(), WebhookHandler {
url: url.to_string(),
world_slug: world_slug.to_string(),
registered_at: chrono::Utc::now().to_rfc3339(),
});
}
pub fn unregister(&mut self, world_slug: &str) {
self.handlers.remove(world_slug);
}
}Step 2: Add webhook delivery to SSE client:
// src/daemon/sse_client.rs — add webhook delivery
pub async fn deliver_via_webhook(
client: &reqwest::Client,
handler: &WebhookHandler,
world_id: &str,
event: &WorldEvent,
) -> Result<Option<WebhookActionResponse>, WebhookError> {
let payload = serde_json::json!({
"worldId": world_id,
"event": event.event_type,
"payload": event.data,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let resp = client.post(&handler.url)
.header("Content-Type", "application/json")
.header("X-AWN-World-Id", world_id)
.header("X-AWN-Event-Id", format!("evt_{}", event.seq))
.json(&payload)
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| WebhookError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(WebhookError::RemoteRejected(resp.status().as_u16()));
}
// Check for action response
let body = resp.text().await.unwrap_or_default();
if body.is_empty() {
return Ok(None);
}
match serde_json::from_str::<WebhookActionResponse>(&body) {
Ok(action_resp) if action_resp.action.is_some() => Ok(Some(action_resp)),
_ => Ok(None),
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebhookActionResponse {
pub action: Option<String>,
pub params: Option<serde_json::Value>,
}
#[derive(Debug, thiserror::Error)]
pub enum WebhookError {
#[error("network error: {0}")]
Network(String),
#[error("remote rejected: HTTP {0}")]
RemoteRejected(u16),
}Step 3: In SSE client event loop, check for webhook and deliver:
// When an event is received from world SSE:
if let Some(handler) = webhook_config.handlers.get(&world_slug) {
tokio::spawn(async move {
match deliver_via_webhook(&client, handler, &world_id, &event).await {
Ok(Some(action_resp)) => {
// Forward action to world: POST /v0/action
forward_webhook_action(&client, &world_endpoint, &action_resp).await;
}
Ok(None) => {} // No action in response
Err(e) => tracing::warn!("webhook delivery failed for {}: {e}", world_slug),
}
});
}Step 4: Wire CLI awn agent set-handler:
// awn agent set-handler casino-poker --webhook https://my-agent.example.com/events
// → Loads ~/.config/awn/webhooks.json, adds handler, saves
// awn agent set-handler casino-poker --remove
// → Removes handlerFiles: src/daemon/webhook.rs (new), src/daemon/sse_client.rs, src/daemon/mod.rs, src/cli/commands.rs
AC:
-
awn agent set-handler <world> --webhook <url>persists to~/.config/awn/webhooks.json - Events POST'd to webhook with
X-AWN-World-Id,X-AWN-Event-Idheaders - Webhook response with
{ "action": "...", "params": {...} }forwarded asPOST /v0/action - 5-second timeout; failed delivery logged, doesn't block event processing
-
awn agent set-handler <world> --removeunregisters webhook - Webhook delivery is async (
tokio::spawn) — non-blocking - Test: event → POST to webhook URL
- Test: webhook responds with action → action forwarded
- Test: webhook timeout → logged, processing continues
- Test: no webhook → normal SSE behavior
Goal: Serve an A2A-compatible Agent Card at /.well-known/agent.json on each world server.
Spec: §05 §3.2 — Announcement includes agentCardUrl; A2A standard — /.well-known/agent.json.
Design: Follow the A2A Agent Card specification for maximum ecosystem interop. The card describes the world as an "agent" that other agents can interact with.
Current State: WorldAnnouncement in gateway/types.rs has a url field. No endpoint serves /.well-known/agent.json. Registration File format in identity/registration_file.rs already references A2A services.
Step 1: Add Agent Card handler:
// src/world_api/server.rs
/// Serves the A2A Agent Card for this world.
/// Public endpoint — no authentication required.
/// Format follows A2A spec: https://google.github.io/A2A/specification/
pub async fn agent_card_handler(
State(state): State<Arc<EndpointState>>,
) -> Json<serde_json::Value> {
let manifest = state.world_runtime.lock().await.manifest().clone();
let listen_addr = &state.base.config.listen_addr;
let actions_as_skills: Vec<serde_json::Value> = manifest.actions.iter().map(|a| {
serde_json::json!({
"id": a.name,
"name": a.name,
"description": a.description.as_deref().unwrap_or(""),
"tags": [manifest.world.category.as_deref().unwrap_or("general")],
"inputModes": ["application/json"],
"outputModes": ["application/json"],
})
}).collect();
Json(serde_json::json!({
"name": manifest.world.name,
"description": manifest.world.description.as_deref()
.unwrap_or(&format!("AWN World: {}", manifest.world.slug)),
"url": format!("http://{}", listen_addr),
"version": crate::version::PROTOCOL_VERSION,
"provider": {
"organization": "AgentWorlds",
"url": "https://agentworlds.ai"
},
"capabilities": {
"streaming": true,
"pushNotifications": false,
"stateTransitionHistory": false
},
"defaultInputModes": ["application/json"],
"defaultOutputModes": ["application/json", "text/event-stream"],
"skills": actions_as_skills,
"security": [{
"scheme": "AWN-Ed25519",
"description": "Ed25519 signed auth tokens per AWN SPEC §10"
}],
"protocolVersion": crate::version::PROTOCOL_VERSION,
"awnExtensions": {
"worldId": state.world_id().unwrap_or_default(),
"category": manifest.world.category.as_deref().unwrap_or("general"),
"protocolType": manifest.protocol.as_ref()
.and_then(|p| p.protocol_type.as_deref())
.unwrap_or("turn-based"),
"maxAgents": manifest.world.max_agents.unwrap_or(6),
}
}))
}Step 2: Register route (unauthenticated, public):
// Before the auth middleware layer — this route must be public
.route("/.well-known/agent.json", get(agent_card_handler))Step 3: Add awnExtensions block for AWN-specific metadata that A2A clients can use to understand world capabilities.
Files: src/world_api/server.rs
AC:
-
GET /.well-known/agent.jsonreturns A2A-compatible Agent Card - Public endpoint (no auth) — outside the auth middleware
- Card includes: name, description, url, version, capabilities, skills (from manifest actions)
-
awnExtensionsblock includes: worldId, category, protocolType, maxAgents - Skills derived from
WorldManifest.actions— each action becomes a skill - Test: GET returns valid JSON with all required A2A fields
- Test: manifest with 3 actions → card has 3 skills
- Test: no auth required (unauthenticated request succeeds)
Goal: Check if a target agent is reachable (has active SSE connection to this world).
Spec: CLI §7.6 — awn ping <agent-id>
Design: Since agents don't expose endpoints to each other, "reachable" means "has an active SSE subscription." The EventBus uses broadcast::Sender, but we need to track which agents have active receivers.
Step 1: Add connection tracking to EventBus:
// src/world_api/sse.rs — add connected agent tracking
pub struct EventBus {
// ... existing fields ...
/// Set of agent AWN IDs with active SSE subscriptions.
connected_agents: RwLock<HashSet<String>>,
}
impl EventBus {
/// Record that an agent has connected to the SSE stream.
pub fn mark_connected(&self, agent_id: &str) {
self.connected_agents.write().unwrap().insert(agent_id.to_string());
}
/// Record that an agent has disconnected.
pub fn mark_disconnected(&self, agent_id: &str) {
self.connected_agents.write().unwrap().remove(agent_id);
}
/// Check if an agent has an active SSE connection.
pub fn is_connected(&self, agent_id: &str) -> bool {
self.connected_agents.read().unwrap().contains(agent_id)
}
}Step 2: Update events_handler (SSE endpoint) to call mark_connected on stream open and mark_disconnected on stream close (via drop guard).
Step 3: Add ping endpoint:
pub async fn ping_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
Json(req): Json<PingRequest>,
) -> Result<Json<ApiResponse<PingResponse>>, ApiError> {
// Verify caller joined
// Verify target joined (in registry)
// Check SSE connection status
let reachable = state.event_bus.is_connected(&req.target);
Ok(Json(ApiResponse::ok(PingResponse {
target: req.target,
reachable,
connected_via: if reachable { Some("sse".to_string()) } else { None },
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingRequest { pub target: String }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PingResponse {
pub target: String,
pub reachable: bool,
pub connected_via: Option<String>,
}Step 4: Wire CLI:
// awn ping --world <slug> --target <agent-id>
// Output: "aw:sha256:abc123 is reachable (via SSE)" or "unreachable"Files: src/world_api/sse.rs, src/world_api/endpoints.rs, src/world_api/server.rs, src/cli/commands.rs
AC:
-
EventBustracks connected agents viaconnected_agents: HashSet - SSE handler calls
mark_connectedon open,mark_disconnectedon close -
POST /v0/pingreturns{ reachable: true/false } - Both caller and target must be joined
-
awn pingCLI command works - Test: agent with active SSE → reachable
- Test: agent without SSE → unreachable
- Test: non-joined target → error
| File | Change Type | LoC | Description |
|---|---|---|---|
src/world_api/messages.rs |
New | ~120 | Message types, inbox constants |
src/world_api/endpoints.rs |
Modified | ~180 | agents_handler, send/get messages, ping, inbox cleanup |
src/world_api/server.rs |
Modified | ~50 | New routes + agent_card_handler |
src/world_api/sse.rs |
Modified | ~30 | connected_agents tracking + is_connected() |
src/world_api/mod.rs |
Modified | ~2 | pub mod messages; |
src/daemon/webhook.rs |
New | ~100 | WebhookConfig + persistence |
src/daemon/sse_client.rs |
Modified | ~60 | Webhook delivery + action forwarding |
src/daemon/mod.rs |
Modified | ~25 | Online/offline marker + pub mod webhook |
src/cli/commands.rs |
Modified | ~200 | All 6 commands: agents, send, messages, ping, agent status/offline/online, set-handler |
src/cli/mod.rs |
Modified | ~5 | Exports |
Total: ~900 LoC net new across 10+ files
| Method | Path | Auth | Rate Limit | Description |
|---|---|---|---|---|
GET |
/v0/agents |
AWN-Ed25519 | 120/min | List joined agents |
POST |
/v0/messages |
AWN-Ed25519 | 30/min | Send message to agent |
GET |
/v0/messages |
AWN-Ed25519 | 60/min | Read inbox |
POST |
/v0/ping |
AWN-Ed25519 | 30/min | Check agent reachability |
GET |
/.well-known/agent.json |
None | — | A2A Agent Card (public) |
| Event | SSE Field | Visibility | Description |
|---|---|---|---|
message.received |
world.message |
Private (recipient only) | New message in inbox |
| Command | Description |
|---|---|
awn agents --world <slug> |
List peers in world |
awn send --world <slug> --to <id> --message <json> |
Send message |
awn messages --world <slug> [--peek] [--limit N] |
Read inbox |
awn ping --world <slug> --target <id> |
Check reachability |
awn agent status |
Show agent connectivity |
awn agent offline |
Pause gateway announcements |
awn agent online |
Resume gateway announcements |
awn agent set-handler <world> --webhook <url> |
Register webhook |
US-005 (Agent Card) ─── independent, smallest
US-001 (Agent listing) ─── independent, foundation for others
US-006 (Ping) ─── depends on US-001 (needs registry check) + SSE tracking
US-002 (Messaging) ─── depends on US-001 (needs registry for recipient validation)
US-003 (Agent lifecycle) ─── independent, daemon-only changes
US-004 (Webhook delivery) ─── independent, daemon-only changes
Recommended commit order:
1. US-005 (Agent Card) — smallest, independent, immediate value
2. US-001 (Agent listing) — foundation for US-002 and US-006
3. US-006 (Ping) — small, builds on SSE tracking
4. US-002 (Messaging) — largest world_api change
5. US-003 (Agent lifecycle) — daemon changes, independent
6. US-004 (Webhook delivery) — daemon changes, independent
| Test | Story | Validates |
|---|---|---|
agents_list_all_joined |
US-001 | 3 joined → all 3 returned |
agents_requires_membership |
US-001 | Non-joined → NOT_JOINED |
agents_updates_on_leave |
US-001 | Leave → removed from list |
send_message_success |
US-002 | A→B → stored + SSE event |
send_to_non_joined |
US-002 | → error |
send_self_allowed |
US-002 | Agent can message self |
get_messages_consume |
US-002 | Read → inbox emptied |
get_messages_peek |
US-002 | ?peek=true → inbox preserved |
get_messages_since |
US-002 | ?since filter works |
inbox_overflow |
US-002 | 101 messages → oldest dropped |
inbox_cleanup_on_leave |
US-002 | Leave → inbox removed |
sse_message_received_event |
US-002 | Send → SSE private event to recipient |
agent_status_running |
US-003 | Daemon running → shows online |
agent_offline_marker |
US-003 | Offline → marker removed |
agent_online_marker |
US-003 | Online → marker created |
webhook_config_persist |
US-004 | Register → save → load → handler present |
webhook_delivery_success |
US-004 | POST 200 → Ok |
webhook_action_forward |
US-004 | Response has action → forwarded |
webhook_timeout |
US-004 | 5s timeout → WebhookError |
agent_card_serves_valid_json |
US-005 | GET → valid A2A-compatible JSON |
agent_card_skills_from_manifest |
US-005 | Manifest actions → skills |
agent_card_no_auth_required |
US-005 | Public endpoint |
ping_connected_agent |
US-006 | SSE active → reachable: true |
ping_disconnected |
US-006 | No SSE → reachable: false |
ping_non_joined_target |
US-006 | → error |
sse_connected_tracking |
US-006 | Connect → mark, disconnect → unmark |
| Test | Stories | Validates |
|---|---|---|
agent_communication_e2e |
001+002+006 | Join → list → ping → send → read → leave |
webhook_action_loop |
004 | Join → action → webhook gets turn event → responds with action |
agent_card_matches_announcement |
005 | Card URL in announcement → actual card served |
| Feature | Why | Upgrade Path |
|---|---|---|
A2A tasks/send delegation |
Large protocol (~2000 LoC); wrong abstraction for game turns | Future PR — Layer 3 |
| MCP Server exposure | Separate concern (agent-to-tool, not agent-to-agent) | Future PR — Layer 4 |
| WebSocket transport | Not needed until sub-10ms latency required (market-trade v2) | Future PR |
| NATS message bus | Infrastructure dependency; not needed until 1000+ concurrent agents | Future PR |
| gRPC endpoints | Proto toolchain overhead; not needed for MVP | Future PR |
| Message persistence (SQLite) | In-memory sufficient for MVP; messages are ephemeral | Future PR |
| E2E message encryption | Requires key exchange; world-relayed model means world can read messages | Future PR |
| Cross-world messaging | Requires federation; agents must be in same world for MVP | Future PR (A2A Layer 3) |
| Daemon IPC socket | CLI currently calls world HTTP directly; full IPC needed for awn events |
Future PR |
| Risk | Level | Mitigation |
|---|---|---|
| In-memory inbox lost on world restart | Medium | Documented as MVP limitation; messages are ephemeral (not receipts) |
| Message spam between agents | Low | Rate limited (30/min send); inbox capped (100 msgs) |
| Webhook URL injection/SSRF | Medium | Validate URL scheme (HTTPS only for non-localhost); 5s timeout |
| Agent Card schema drift from A2A spec | Low | Implement minimal required fields; awnExtensions for custom data |
connected_agents stale entries |
Low | Drop guard in SSE handler ensures cleanup on disconnect |
| CLI needs to know world endpoint | Medium | For MVP: user passes --world <slug>, CLI resolves via daemon state or discovery |
- US-001:
AgentRegistryalready has the data. Useregistry.agents.read().awaitto iterateAgentInfovalues. Zip withsessionsforjoined_at. - US-002: Use
VecDeque<AgentMessage>—push_back()for new,pop_front()for overflow. Drain withdrain(..)for consume-on-read. - US-002: The
EventBus::send_to()signature issend_to(&self, agent_id, event_type, sse_event, payload). Useevent_type: "world.message",sse_event: "message.received". - US-003: The online marker file approach avoids needing daemon IPC. The announcer (if it exists as a heartbeat loop) checks the file on each iteration.
- US-004: Webhook config path: use
dirs.config_dir().join("webhooks.json"). Atomic write pattern: write temp → fsync → rename. - US-005: The Agent Card route MUST be registered OUTSIDE the auth middleware layer. In
server.rs, there are two router segments (public and authenticated). Add/.well-known/agent.jsonto the public router. - US-006: For SSE connection tracking, use a
Dropguard struct. When the SSE stream ends (client disconnects or agent leaves), the guard callsmark_disconnected(). - Run
cargo fmtandcargo clippybefore every commit. CI is strict. - Keep
#[serde(rename_all = "camelCase")]on all new serializable structs. - Rate limits: Use the existing rate limit infrastructure in
server.rs. Match spec §10 defaults.
Review — 2026-04-06
The plan was written against an older codebase snapshot. The current branch (
hal/stage-progression-rate-limits) has already shipped most of the server-side work:GET /v0/agentsserver.rs:1222, handler atendpoints.rs:390-420POST/GET /v0/messagessrc/world_api/messages.rs, routes inserver.rs/.well-known/agent.jsonserver.rsPOST /v0/pingserver.rs, EventBusconnected_agentstracking atsse.rs:133AgentSubcommandhasStatus/Offline/Onlinevariants (commands.rs:1325-1332), logic needs verificationsse_client.rshas no webhook codeFactual Errors
Status/Offline/Onlinesince the stage-progression branchagent_card_urlfield exists atgateway/types.rs:61server.rs:313-394, actualserver.rs:390-394server.rs:296-306, actualserver.rs:374-382endpoints.rs:266-278, actualendpoints.rs:349-361LoC Estimate Off
What Actually Remains for PR #39
awn agents,awn ping,awn send,awn messagesall still route todeferred_output()atcommands.rs:248-253. Need real implementations that call the existing HTTP endpoints.src/daemon/sse_client.rsreceives events but has no webhook forwarding. This is the largest remaining piece (~160 LoC).Recommendation
Rewrite the plan to reflect current state. Options: