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 after trust chain completion
Estimated: ~800 LoC net new across 12 files
Coverage lift: Completes CLI §7.6 and §7.7; advances SPEC §02 §10 event delivery
With PR #38 closing the 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 they cannot discover who else is in the world, cannot send messages to each other, and cannot control their own online/offline presence.
- The CLI defines 4 deferred commands (
awn agents,awn ping,awn send,awn messages) and 3 agent lifecycle commands (awn agent status/offline/online) — all return "not implemented yet". - The spec (§02 §10) defines 4 event delivery modes (CLI/IPC, Programmatic, Webhook, A2A) but only SSE World→Agent is implemented.
- Worlds announce an
agentCardUrlto the gateway but no endpoint actually serves it.
This PR establishes the agent communication foundations: peer listing, in-world messaging relay, agent lifecycle, webhook event delivery, and the Agent Card endpoint.
| # | Gap | Spec Section | Current State |
|---|---|---|---|
| 1 | No GET /v0/agents endpoint — agents can't see who's in the world |
§10 §3, CLI §7.6 | AgentRegistry stores agents + has agent_ids(), but no HTTP endpoint exposes it |
| 2 | No in-world messaging (awn send, awn messages) |
CLI §7.6 | CLI stubs only, no message relay in world API |
| 3 | Agent lifecycle commands (awn agent status/offline/online) not implemented |
CLI §7.7, §19.1 | AgentCommand enum exists in CLI but dispatches to deferred message |
| 4 | No webhook event delivery | §02 §10 | Only SSE World→Agent exists; no webhook POST mode |
| 5 | Agent Card endpoint not served | §05 §3.2, §01 §4 | agentCardUrl announced to gateway, but no HTTP handler serves /.well-known/agent.json |
| 6 | awn ping <agent-id> not implemented |
CLI §7.6 | CLI stub only |
| Component | File:Line | What It Provides |
|---|---|---|
AgentRegistry |
src/world_api/server.rs:313-394 |
In-memory agent tracking; register(), remove(), get(), agent_ids() |
AgentInfo |
src/world_api/server.rs:296-306 |
Per-agent: awn_id, public_key, subject_id, slot |
SessionInfo |
src/world_api/endpoints.rs:266-278 |
Per-session: session_id, slot, joined_at, active |
| Deferred CLI commands | src/cli/commands.rs:166 |
Agents, Ping, Send, Messages, Agent(...) all return deferred message |
EventBus (SSE) |
src/world_api/sse.rs:135-150 |
broadcast(), send_to(agent_id) for SSE events |
WorldEvent |
src/world_api/sse.rs:39-53 |
event_type, data, visibility (Public/Private/Agents), signature |
EventVisibility |
src/world_runtime/mod.rs |
Public, Private(String), Agents |
| Gateway announcer | src/daemon/announcer.rs:60-84 |
Generates agentCardUrl path, but no endpoint serves it |
| World API routes | src/world_api/server.rs:1024-1036 |
11 endpoints; no /v0/agents or /v0/messages |
| SSE endpoint | src/world_api/server.rs:1032 |
GET /v0/events — functional SSE push |
| CLI events (stub) | src/cli/events.rs:48-53 |
Returns NOT_IMPLEMENTED; test version works |
Goal: Let agents see who else is in the world — enabling peer discovery for messaging and coordination.
Spec: CLI §7.6 — awn agents "inspect peers"; §10 §3 — Join response shows players, but no live listing endpoint exists.
Current State: AgentRegistry in server.rs:313 stores all joined agents and has agent_ids() method (line 391). But no HTTP endpoint exposes this data.
Step 1: Add GET /v0/agents endpoint to the World API:
// src/world_api/endpoints.rs — new handler
/// List all agents currently joined to this world.
/// Auth required (must be joined).
async fn agents_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
) -> Result<Json<ApiResponse<AgentsResponse>>, ApiError> {
// Verify caller 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 list agents"));
}
let registry = state.base.registry.agents.read().await;
let agents: Vec<AgentSummary> = registry.values().map(|info| {
let session = sessions.get(&info.awn_id);
AgentSummary {
awn_id: info.awn_id.clone(),
subject_id: info.subject_id.clone(),
slot: info.slot,
joined_at: session.map(|s| s.joined_at.clone()),
metadata: None, // Extend later with agent-provided metadata
}
}).collect();
Ok(Json(ApiResponse::ok(AgentsResponse {
agents,
count: agents.len(),
})))
}
#[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: Register route in server.rs:
.route("/v0/agents", get(agents_handler))Step 3: Wire awn agents CLI command to call this endpoint:
// src/cli/commands.rs — implement Agents command
Command::Agents => {
// Call GET /v0/agents on the currently-joined world
// Requires: active session tracking (which world is the agent in?)
// For MVP: accept world slug as argument
}Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/cli/commands.rs
AC:
-
GET /v0/agentsreturns list of joined agents with awn_id, subject_id, slot, joined_at - Only joined agents can call this endpoint (auth + membership check)
- Response uses standard
{ok, data}envelope - Rate limit: 120/min per agent (same as
GET /v0/state) -
awn agents <world>CLI command calls endpoint and displays results - Test: 3 agents joined → list returns all 3
- Test: non-joined agent → 401 NOT_JOINED
- Test: agent leaves → removed from list
Goal: Enable agents in the same world to send direct messages to each other via the world as relay.
Spec: CLI §7.6 — awn send <agent-id> <message> and awn messages — "send direct messages; read inbound messages"
Current State: No messaging infrastructure exists. The EventBus supports send_to(agent_id) for SSE events, which could be extended.
The spec doesn't mandate a specific delivery mechanism. For MVP, world-relayed messaging is the simplest and most secure approach:
- Messages are POST'd to the world, which stores them in a per-agent inbox
- Recipients fetch messages via GET or receive them via SSE push
- World can enforce rate limits, content policies, and audit
- No need for agents to know each other's endpoints
Step 1: Add message types:
// src/world_api/endpoints.rs
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendMessageRequest {
pub to: String, // Target agent awn_id
pub content: serde_json::Value, // Message payload (JSON)
pub content_type: Option<String>, // MIME type hint (default: "application/json")
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Message {
pub message_id: String, // UUIDv7
pub from: String, // Sender awn_id
pub to: String, // Recipient awn_id
pub content: serde_json::Value, // Message payload
pub content_type: String, // MIME type
pub sent_at: String, // ISO 8601
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesResponse {
pub messages: Vec<Message>,
pub count: usize,
}Step 2: Add in-memory message inbox to EndpointState:
// In EndpointState
/// Per-agent message inbox. Key = recipient awn_id.
/// Messages are kept until read or agent leaves (bounded at 100 per agent).
pub message_inbox: RwLock<HashMap<String, VecDeque<Message>>>,Step 3: Add POST /v0/messages handler:
async fn send_message_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
Json(req): Json<SendMessageRequest>,
) -> Result<Json<ApiResponse<Message>>, ApiError> {
// 1. Verify sender is joined
// 2. Verify recipient is joined (registry.contains(&req.to))
// 3. Check inbox capacity (max 100 per agent)
// 4. Create Message with UUIDv7 id
// 5. Push to recipient's inbox
// 6. Push SSE event to recipient: "message.received"
// 7. Return created message
}Step 4: Add GET /v0/messages handler:
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 and drain messages from inbox (consume-on-read)
// 3. Optional: ?peek=true to read without consuming
// 4. Optional: ?since=<timestamp> to filter
// 5. Return messages
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesQuery {
pub peek: Option<bool>, // Don't consume messages
pub since: Option<String>, // Filter by timestamp
pub limit: Option<u32>, // Max messages (default 50)
}Step 5: Wire CLI commands:
// awn send <world> <agent-id> <message>
Command::Send { world, agent_id, message } => {
// POST /v0/messages with { to: agent_id, content: message }
}
// awn messages <world>
Command::Messages { world } => {
// GET /v0/messages
}Step 6: Push SSE notification on message receipt:
// In send_message_handler, after storing message:
state.event_bus.send_to(&req.to, WorldEvent {
event_type: "message.received".to_string(),
data: serde_json::json!({
"messageId": message.message_id,
"from": agent.awn_id,
"contentType": message.content_type,
"preview": truncate(&message.content.to_string(), 100),
}),
visibility: EventVisibility::Private(req.to.clone()),
});Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/cli/commands.rs
AC:
-
POST /v0/messagessends message to agent in same world -
GET /v0/messagesretrieves inbox (consume-on-read by default,?peek=trueto keep) - Sender and recipient must both be joined
- Messages are JSON payloads (not plain text only)
- Per-agent inbox capped at 100 messages (oldest dropped on overflow)
- SSE
message.receivedevent pushed to recipient - Messages cleaned up when agent leaves
- Rate limit: 30/min per agent for send, 60/min for read
-
awn send <world> <agent-id> <message>CLI works -
awn messages <world>CLI works - Test: A sends to B → B receives via GET
- Test: A sends to B → B gets SSE event
- Test: send to non-joined agent → error
- Test: inbox overflow → oldest dropped
- Test: agent leaves → inbox cleared
Goal: Let agents check their status and toggle online/offline presence without stopping the daemon.
Spec: CLI §7.7 and §19.1 — awn agent status shows connectivity; awn agent offline unannounces from gateway; awn agent online re-announces.
Current State: AgentCommand enum exists in src/cli/commands.rs with Status, Offline, Online variants, but all dispatch to deferred message. No announcer pause/resume exists.
Step 1: Implement awn agent status:
// src/cli/commands.rs — AgentCommand::Status handler
// Read daemon status (PID file, socket)
let status = daemon_status(&dirs)?;
// Read identity
let identity = load_identity(&dirs)?;
// Build output:
// Agent
// ID: aw:sha256:abc123...
// Status: online | offline | daemon-stopped
// Gateway: connected | disconnected
// Uptime: 2h 15m
// Worlds
// Joined: N
// Hosting: MThis builds on existing daemon_status() from src/daemon/mod.rs and identity loading from src/identity/state.rs.
Step 2: Add online/offline state to daemon:
// src/daemon/compound.rs — add state file
const AGENT_ONLINE_FILE: &str = "agent-online";
impl CompoundDaemon {
pub fn is_online(&self) -> bool {
self.state_dir.join(AGENT_ONLINE_FILE).exists()
}
pub fn set_online(&self, online: bool) -> std::io::Result<()> {
let path = self.state_dir.join(AGENT_ONLINE_FILE);
if online {
std::fs::write(&path, "1")?;
// Resume gateway announcements
self.announcer.resume();
} else {
let _ = std::fs::remove_file(&path);
// Pause gateway announcements
self.announcer.pause();
}
Ok(())
}
}Step 3: Add pause/resume to GatewayAnnouncer:
// src/daemon/announcer.rs
pub struct GatewayAnnouncer {
// ... existing fields ...
paused: Arc<AtomicBool>,
}
impl GatewayAnnouncer {
pub fn pause(&self) {
self.paused.store(true, Ordering::SeqCst);
// Send de-announce to gateway
}
pub fn resume(&self) {
self.paused.store(false, Ordering::SeqCst);
// Send announce to gateway
}
// In heartbeat loop:
// if self.paused.load(Ordering::SeqCst) { continue; }
}Step 4: Implement awn agent offline/online CLI commands:
// awn agent offline → write state file + call daemon to pause announcer
// awn agent online → write state file + call daemon to resume announcerFiles: src/cli/commands.rs, src/daemon/compound.rs, src/daemon/announcer.rs
AC:
-
awn agent statusshows: agent ID, status (online/offline/stopped), gateway connection, uptime, joined worlds count -
awn agent offlinepauses gateway announcements, writes state file -
awn agent onlineresumes gateway announcements, removes state file - Offline state persists across daemon restarts (state file based)
- Joined worlds remain joined during offline (connections not dropped)
- Test: status shows correct online/offline state
- Test: offline → announcer paused
- Test: online → announcer resumed
Goal: Enable agents to register a webhook URL for event delivery, so the daemon POSTs events to the agent's HTTP endpoint instead of requiring SSE polling.
Spec: §02 §10 — "Webhook: HTTP POST to registered URL. Agent responds with action in HTTP response body."
Current State: Only SSE (World→Agent) exists. No webhook registration or delivery mechanism.
Step 1: Add webhook handler registration:
// src/daemon/compound.rs or new file src/daemon/webhook.rs
pub struct WebhookConfig {
/// Map of world_slug → webhook URL
pub handlers: HashMap<String, WebhookHandler>,
}
pub struct WebhookHandler {
pub url: String,
pub world_slug: String,
pub registered_at: String,
}
impl WebhookConfig {
pub fn from_file(path: &Path) -> Self { /* read ~/.config/awn/webhooks.json */ }
pub fn save(&self, path: &Path) -> Result<()> { /* atomic write */ }
pub fn register(&mut self, world_slug: &str, url: &str) { /* add handler */ }
pub fn unregister(&mut self, world_slug: &str) { /* remove handler */ }
}Step 2: Add webhook delivery in SSE client:
// src/daemon/sse_client.rs — when event received:
// 1. If webhook registered for this world:
// POST event to webhook URL
// If response contains action → forward to world via POST /v0/action
// 2. Else: store in event buffer for CLI consumption
async fn deliver_event(&self, world_slug: &str, event: &WorldEvent) {
if let Some(handler) = self.webhook_config.handlers.get(world_slug) {
let payload = serde_json::json!({
"worldId": self.world_id,
"event": event.event_type,
"payload": event.data,
"timestamp": Utc::now().to_rfc3339(),
});
match self.client.post(&handler.url)
.header("X-AWN-World-Id", &self.world_id)
.header("X-AWN-Event-Id", &event.id)
.json(&payload)
.timeout(Duration::from_secs(5))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
// Check if response body contains an action
if let Ok(action_resp) = resp.json::<WebhookActionResponse>().await {
if let Some(action) = action_resp.action {
// Forward action to world
self.forward_action(world_slug, &action, &action_resp.params).await;
}
}
}
Ok(resp) => {
tracing::warn!("webhook delivery failed: HTTP {}", resp.status());
}
Err(e) => {
tracing::warn!("webhook delivery failed: {e}");
}
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebhookActionResponse {
pub action: Option<String>,
pub params: Option<serde_json::Value>,
}Step 3: Add CLI command for webhook registration:
// awn agent set-handler <world-slug> --webhook <url>
// Persists to ~/.config/awn/webhooks.jsonFiles: 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>registers webhook - Webhook config persisted to
~/.config/awn/webhooks.json - Events POST'd to webhook URL with
X-AWN-World-IdandX-AWN-Event-Idheaders - Webhook response with
{ "action": "...", "params": {...} }forwarded to world - 5-second timeout on webhook delivery
- Failed delivery logged but doesn't block event processing
-
awn agent set-handler <world> --removeunregisters webhook - Test: register webhook → event delivered via POST
- Test: webhook returns action → action forwarded to world
- Test: webhook timeout → logged, event processing continues
- Test: no webhook → default SSE behavior
Goal: Serve an A2A-compatible Agent Card at /.well-known/agent.json on the world server, as referenced in gateway announcements.
Spec: §05 §3.2 — Announcement includes agentCardUrl; §01 §4 — Registration File format with A2A service endpoint.
Current State: announcer.rs:60-84 generates the agentCardUrl path for announcements, but no HTTP endpoint serves it. Announced URL points to nothing.
Step 1: Add Agent Card endpoint to the World API server:
// src/world_api/server.rs
async fn agent_card_handler(
State(state): State<Arc<WorldApiState>>,
) -> Json<serde_json::Value> {
let manifest = &state.config;
Json(serde_json::json!({
"name": manifest.world_slug.as_deref().unwrap_or("unknown"),
"description": format!("AWN World: {}", manifest.world_slug.as_deref().unwrap_or("unknown")),
"url": format!("http://{}", manifest.listen_addr),
"version": crate::version::PROTOCOL_VERSION,
"capabilities": {
"streaming": true,
"pushNotifications": false,
"stateTransitionHistory": false,
},
"defaultInputModes": ["application/json"],
"defaultOutputModes": ["application/json"],
"skills": [{
"id": "world-interaction",
"name": "World Interaction",
"description": "Join, act, and leave this AWN World",
"inputModes": ["application/json"],
"outputModes": ["application/json", "text/event-stream"],
}]
}))
}Step 2: Register route (unauthenticated, public):
// In server.rs route setup
.route("/.well-known/agent.json", get(agent_card_handler))Step 3: Populate from WorldManifest when available:
// If EndpointState has manifest data, use it for richer card:
// - name from manifest.name
// - description from manifest.description
// - category from manifest.category
// - actions from manifest.actions (as skills)Files: src/world_api/server.rs
AC:
-
GET /.well-known/agent.jsonreturns A2A-compatible Agent Card JSON - Endpoint is public (no auth required)
- Card includes: name, description, url, version, capabilities, skills
- When manifest available: card includes category and actions as skills
- Test: GET returns valid JSON with required fields
- Test: Card URL matches what announcer generates
Goal: Check if an agent is reachable by sending a lightweight probe through the world relay.
Spec: CLI §7.6 — awn ping <agent-id>
Current State: CLI stub only. No ping mechanism.
Since agents don't expose direct endpoints to each other, awn ping works through the world:
- Sender POST's a ping message to the world
- World pushes SSE
ping.requestevent to target agent - If target is connected (SSE active), world returns
pongwithin timeout - If target is not connected, returns timeout
Step 1: Add POST /v0/ping endpoint:
async fn ping_handler(
agent: AuthenticatedAgent,
State(state): State<Arc<EndpointState>>,
Json(req): Json<PingRequest>,
) -> Result<Json<ApiResponse<PingResponse>>, ApiError> {
// 1. Verify sender is joined
// 2. Verify target is joined (registry.contains(&req.target))
// 3. Check target's SSE connection is active (event_bus.is_connected(&req.target))
// 4. Return pong with latency info
let target_connected = state.event_bus.is_connected(&req.target);
Ok(Json(ApiResponse::ok(PingResponse {
target: req.target.clone(),
reachable: target_connected,
latency_ms: None, // Would need round-trip for actual latency
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingRequest {
pub target: String, // Target agent awn_id
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PingResponse {
pub target: String,
pub reachable: bool,
pub latency_ms: Option<u64>,
}Step 2: Add is_connected() to EventBus:
// src/world_api/sse.rs
impl EventBus {
pub fn is_connected(&self, agent_id: &str) -> bool {
// Check if agent has an active SSE sender
self.senders.read().unwrap().contains_key(agent_id)
}
}Step 3: Wire CLI:
// awn ping <world> <agent-id>
// → POST /v0/ping { target: agent_id }
// → Display: "Agent aw:sha256:abc123 is reachable" or "Agent unreachable"Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/world_api/sse.rs, src/cli/commands.rs
AC:
-
POST /v0/pingchecks if target agent's SSE connection is active - Returns
{ reachable: true/false }— no actual round-trip yet - Both sender and target must be joined
-
awn ping <world> <agent-id>CLI command works - Test: connected agent → reachable: true
- Test: disconnected agent → reachable: false
- Test: non-joined target → error
| File | Change Type | Description |
|---|---|---|
src/world_api/endpoints.rs |
Modified | ~200 LoC — agents_handler, send/get messages, ping |
src/world_api/server.rs |
Modified | ~40 LoC — new routes + agent_card_handler |
src/world_api/sse.rs |
Modified | ~10 LoC — add is_connected() |
src/daemon/webhook.rs |
New | ~120 LoC — WebhookConfig + delivery logic |
src/daemon/sse_client.rs |
Modified | ~50 LoC — webhook delivery integration |
src/daemon/announcer.rs |
Modified | ~30 LoC — pause/resume mechanism |
src/daemon/compound.rs |
Modified | ~30 LoC — online/offline state |
src/daemon/mod.rs |
Modified | ~5 LoC — pub mod webhook |
src/cli/commands.rs |
Modified | ~150 LoC — implement agents, ping, send, messages, agent status/offline/online |
src/cli/mod.rs |
Modified | ~5 LoC — exports |
Total: ~800 LoC net new across 10 files
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/v0/agents |
AWN-Ed25519 | List joined agents |
POST |
/v0/messages |
AWN-Ed25519 | Send message to agent |
GET |
/v0/messages |
AWN-Ed25519 | Read inbox |
POST |
/v0/ping |
AWN-Ed25519 | Check agent reachability |
GET |
/.well-known/agent.json |
None (public) | A2A Agent Card |
US-001 (Agent listing) ─── independent, prerequisite for messaging
US-002 (Messaging) ─── depends on US-001 (needs agent list for validation)
US-003 (Agent lifecycle) ─── independent
US-004 (Webhook delivery) ─── independent
US-005 (Agent Card) ─── independent
US-006 (Ping) ─── depends on US-001 (needs agent list), depends on SSE is_connected
Recommended order:
US-005 first (smallest, independent)
→ US-001 (foundation for messaging + ping)
→ US-006 (small, builds on US-001)
→ US-002 (largest, builds on US-001)
→ US-003 (independent, daemon changes)
→ US-004 (independent, daemon changes)
| Test | Story | Validates |
|---|---|---|
agents_list_joined |
US-001 | 3 agents joined → list returns all 3 |
agents_list_requires_membership |
US-001 | Non-joined → 401 NOT_JOINED |
agents_list_after_leave |
US-001 | Agent leaves → removed from list |
send_message_success |
US-002 | A→B message stored + SSE event |
send_to_non_joined |
US-002 | → error |
get_messages_consume |
US-002 | Read → inbox emptied |
get_messages_peek |
US-002 | ?peek=true → inbox preserved |
inbox_overflow |
US-002 | >100 messages → oldest dropped |
messages_cleanup_on_leave |
US-002 | Leave → inbox cleared |
sse_message_received_event |
US-002 | Message → SSE event to recipient |
agent_status_online |
US-003 | Status shows online when daemon running |
agent_offline_pauses_announcer |
US-003 | offline → announcer paused |
agent_online_resumes_announcer |
US-003 | online → announcer resumed |
webhook_delivery_success |
US-004 | Event → POST to webhook URL |
webhook_action_forward |
US-004 | Webhook returns action → forwarded |
webhook_timeout |
US-004 | 5s timeout → logged, continues |
agent_card_serves |
US-005 | GET /.well-known/agent.json → valid JSON |
agent_card_no_auth |
US-005 | Public endpoint, no auth required |
ping_reachable |
US-006 | Connected agent → reachable: true |
ping_unreachable |
US-006 | Disconnected → reachable: false |
ping_non_joined |
US-006 | Non-joined target → error |
| Test | Story | Validates |
|---|---|---|
agent_communication_e2e |
US-001+002+006 | A joins → B joins → A lists agents → A pings B → A sends message → B reads inbox |
webhook_event_loop |
US-004 | Join → action → webhook receives turn event → webhook responds with action |
| Feature | Why | Where |
|---|---|---|
| A2A JSON-RPC 2.0 protocol | Large protocol implementation; Phase 5+ | Future PR |
awn agent set-handler --a2a |
Requires A2A implementation | Future PR |
| Daemon IPC socket (Unix domain socket) | Infrastructure change; awn events needs this |
Future PR |
| Message persistence (SQLite) | In-memory sufficient for MVP | Future PR |
| Message encryption (E2E) | Requires key exchange protocol | Future PR |
| Cross-world messaging | Agents must be in same world for MVP | Future PR |
| Rich message types (files, structured data) | JSON payload sufficient for MVP | Future PR |
| Risk | Level | Mitigation |
|---|---|---|
| In-memory inbox lost on restart | Medium | Document as MVP limitation; add SQLite persistence in future |
| Message spam within world | Low | Rate limited (30/min send); inbox capped (100 messages) |
| Webhook URL validation | Low | Only HTTPS URLs accepted; 5s timeout prevents slow-loris |
| Agent Card schema evolves | Low | Return minimal A2A-compatible fields; extend later |
| CLI commands need daemon IPC | Medium | For MVP, CLI calls world HTTP directly (requires knowing world endpoint); full IPC in future PR |
- US-001:
AgentRegistryalready hasagent_ids(). Extend to returnVec<AgentInfo>or iterate values directly. TheAgentInfostruct needs no changes. - US-002: Use
VecDequefor the inbox — O(1) push_back and pop_front. On overflow,pop_front()the oldest. Alwayspush_back()new messages. - US-002: The
message.receivedSSE event should useEventVisibility::Private(to)so only the recipient sees it. - US-003: For offline/online, the simplest approach is a state file in the daemon state directory. The announcer checks this on each heartbeat cycle.
- US-004: Keep webhook delivery async and non-blocking. Use
tokio::spawnfor delivery so it doesn't slow down event processing. - US-005: The Agent Card format follows the A2A spec — minimal fields:
name,url,version,capabilities,skills. - US-006: For MVP,
is_connected()checks the SSE sender map. True latency measurement (round-trip ping) can be added later. - Run
cargo fmtandcargo clippybefore every commit. CI is strict. - Rate limits for new endpoints: match spec §10 recommendations (30/min for write, 60-120/min for read).