Skip to content

Instantly share code, notes, and snippets.

@Jing-yilin
Created April 6, 2026 05:57
Show Gist options
  • Select an option

  • Save Jing-yilin/b071738f07f6eaad6cd4be4255dff850 to your computer and use it in GitHub Desktop.

Select an option

Save Jing-yilin/b071738f07f6eaad6cd4be4255dff850 to your computer and use it in GitHub Desktop.
PR #39 Plan — Agent Communication Foundations (comprehensive, v2)

PR #39 — Agent Communication Foundations

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


Design Context: Communication Architecture Research

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 Fitness Analysis

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

Chosen Architecture: Layered

┌─────────────────────────────────────────────────────────┐
│  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:

  1. World-relayed messaging (not P2P) — simplest, most secure; world enforces rate limits, policies, audit
  2. A2A Agent Card for discovery — zero-cost interop with emerging ecosystem
  3. Keep SSE + POST as core wire protocol — adequate for all 5 world types at current scale
  4. Webhook delivery per spec §02 §10 — enables serverless/cloud agents
  5. In-memory inbox for MVP — messages aren't critical-path data; SQLite persistence later
  6. No NATS/WebSocket/gRPC yet — upgrade paths documented, not MVP requirements

Motivation

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 agentCardUrl but no endpoint serves it

Codebase Anchor Points (verified 2026-04-06)

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

User Stories

US-001: In-World Agent Listing (GET /v0/agents)

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.

Implementation

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/agents returns 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_JOINED otherwise)
  • 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

US-002: In-World Message Relay (POST /v0/messages, GET /v0/messages)

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

Implementation

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/messages stores message in recipient inbox + pushes SSE message.received
  • GET /v0/messages drains inbox (consume-on-read); ?peek=true reads 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 send and awn messages CLI commands work
  • Test: A→B message → B inbox has it → B reads → inbox empty
  • Test: SSE message.received event pushed to B
  • Test: send to non-joined → error
  • Test: inbox overflow (101 messages) → oldest dropped, newest kept
  • Test: agent leaves → inbox cleared

US-003: Agent Lifecycle Commands

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.

Implementation

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 status shows: ID, status (online/offline/daemon-stopped), daemon PID, uptime
  • awn agent offline removes online marker → announcer pauses heartbeat
  • awn agent online writes online marker → announcer resumes
  • Offline persists across daemon restart (file-based)
  • Joined worlds remain joined during offline (not disconnected)
  • Test: status shows correct state
  • Test: offline → heartbeat paused
  • Test: online → heartbeat resumed

US-004: Webhook Event Delivery

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.

Implementation

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 handler

Files: 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-Id headers
  • Webhook response with { "action": "...", "params": {...} } forwarded as POST /v0/action
  • 5-second timeout; failed delivery logged, doesn't block event processing
  • awn agent set-handler <world> --remove unregisters 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

US-005: A2A Agent Card Endpoint

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.

Implementation

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.json returns A2A-compatible Agent Card
  • Public endpoint (no auth) — outside the auth middleware
  • Card includes: name, description, url, version, capabilities, skills (from manifest actions)
  • awnExtensions block 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)

US-006: Agent Ping (POST /v0/ping)

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.

Implementation

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:

  • EventBus tracks connected agents via connected_agents: HashSet
  • SSE handler calls mark_connected on open, mark_disconnected on close
  • POST /v0/ping returns { reachable: true/false }
  • Both caller and target must be joined
  • awn ping CLI command works
  • Test: agent with active SSE → reachable
  • Test: agent without SSE → unreachable
  • Test: non-joined target → error

Files Changed

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


New API Surface

World API Endpoints (+5)

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)

SSE Event Types (+1)

Event SSE Field Visibility Description
message.received world.message Private (recipient only) New message in inbox

CLI Commands (+8)

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

Dependency Graph

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 Plan

Unit Tests (21)

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

Integration Tests (3)

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

Out of Scope

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 Assessment

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

Implementation Notes for hal Agent

  1. US-001: AgentRegistry already has the data. Use registry.agents.read().await to iterate AgentInfo values. Zip with sessions for joined_at.
  2. US-002: Use VecDeque<AgentMessage>push_back() for new, pop_front() for overflow. Drain with drain(..) for consume-on-read.
  3. US-002: The EventBus::send_to() signature is send_to(&self, agent_id, event_type, sse_event, payload). Use event_type: "world.message", sse_event: "message.received".
  4. 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.
  5. US-004: Webhook config path: use dirs.config_dir().join("webhooks.json"). Atomic write pattern: write temp → fsync → rename.
  6. 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.json to the public router.
  7. US-006: For SSE connection tracking, use a Drop guard struct. When the SSE stream ends (client disconnects or agent leaves), the guard calls mark_disconnected().
  8. Run cargo fmt and cargo clippy before every commit. CI is strict.
  9. Keep #[serde(rename_all = "camelCase")] on all new serializable structs.
  10. Rate limits: Use the existing rate limit infrastructure in server.rs. Match spec §10 defaults.
@Jing-yilin

Copy link
Copy Markdown
Author

Review — 2026-04-06

⚠️ Major: Plan is stale — 4 of 6 user stories already implemented

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:

Plan Item Actual State
US-001 GET /v0/agents ✅ Done — server.rs:1222, handler at endpoints.rs:390-420
US-002 POST/GET /v0/messages ✅ Done — types in src/world_api/messages.rs, routes in server.rs
US-005 /.well-known/agent.json ✅ Done — route registered in server.rs
US-006 POST /v0/ping ✅ Done — route in server.rs, EventBus connected_agents tracking at sse.rs:133
US-003 Agent lifecycle subcommands ⚠️ Partially done — AgentSubcommand has Status/Offline/Online variants (commands.rs:1325-1332), logic needs verification
US-004 Webhook event delivery ❌ Not done — sse_client.rs has no webhook code

Factual Errors

  1. Line 79: "AgentSubcommand: Empty enum" — Wrong, has Status/Offline/Online since the stage-progression branch
  2. Line 81: "WorldAnnouncement has url but no agentCardUrl" — Wrong, agent_card_url field exists at gateway/types.rs:61
  3. Line 83: "11 endpoints; no /v0/agents, /v0/messages, /v0/ping, or /.well-known" — Wrong, all 4 routes exist
  4. Anchor point line numbers are all stale (shifted by ~80-100 lines due to recent additions):
    • AgentRegistry: plan says server.rs:313-394, actual server.rs:390-394
    • AgentInfo: plan says server.rs:296-306, actual server.rs:374-382
    • SessionInfo: plan says endpoints.rs:266-278, actual endpoints.rs:349-361

LoC Estimate Off

  • Plan estimates ~900 LoC across 14 files
  • Actual remaining work is ~300-400 LoC: CLI wiring (4 deferred commands), webhook delivery (US-004), agent lifecycle logic verification
  • Server-side endpoints, types, and rate limiting are already in place

What Actually Remains for PR #39

  1. CLI commandsawn agents, awn ping, awn send, awn messages all still route to deferred_output() at commands.rs:248-253. Need real implementations that call the existing HTTP endpoints.
  2. US-004 Webhook delivery — Fully unimplemented. src/daemon/sse_client.rs receives events but has no webhook forwarding. This is the largest remaining piece (~160 LoC).
  3. US-003 Agent lifecycle logic — Subcommand variants exist but need verification that actual online/offline marker logic and announcer pause are wired up.

Recommendation

Rewrite the plan to reflect current state. Options:

  • Option A: Rewrite as a focused ~400 LoC PR covering CLI wiring + webhook + lifecycle verification
  • Option B: Split into PR #39 (CLI + lifecycle, ~200 LoC) and PR #40 (webhook delivery, ~200 LoC)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment