From 2877f3b119c5eec508e4f6e0acf4318cc26b4fa2 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 25 Mar 2026 13:40:20 +0800 Subject: [PATCH 1/3] feat(awn-cli): add `awn action` command for calling world actions - Add new `Action` command to CLI for calling world actions - Add `/ipc/action` endpoint to daemon - Update SKILL.md documentation with action command examples - Add changeset for minor version bump Usage: awn action [params_json] awn action pixel-city set_state '{"state":"idle"}' awn action pixel-city heartbeat Co-Authored-By: Claude Opus 4.5 --- .changeset/feat-awn-action-command.md | 15 +++++ packages/awn-cli/src/daemon.rs | 87 +++++++++++++++++++++++++++ packages/awn-cli/src/main.rs | 64 ++++++++++++++++++++ skills/awn/SKILL.md | 17 ++++++ 4 files changed, 183 insertions(+) create mode 100644 .changeset/feat-awn-action-command.md diff --git a/.changeset/feat-awn-action-command.md b/.changeset/feat-awn-action-command.md new file mode 100644 index 0000000..f5e52cb --- /dev/null +++ b/.changeset/feat-awn-action-command.md @@ -0,0 +1,15 @@ +--- +"awn": minor +--- + +feat(awn-cli): add `awn action` command for calling world actions + +Adds a new CLI command to call actions on joined worlds: + +```bash +awn action [params_json] +awn action pixel-city set_state '{"state":"idle","detail":"Working"}' +awn action pixel-city heartbeat +``` + +This allows agents to interact with world servers by sending signed `world.action` messages. The world server must support the action (check the world manifest for available actions). diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs index a235aea..acfc179 100644 --- a/packages/awn-cli/src/daemon.rs +++ b/packages/awn-cli/src/daemon.rs @@ -158,6 +158,13 @@ pub struct SendMessageBody { pub message: String, } +#[derive(Deserialize)] +pub struct WorldActionBody { + pub world_id: String, + pub action: String, + pub params: serde_json::Value, +} + pub struct DaemonHandle { shutdown_tx: oneshot::Sender<()>, pub addr: SocketAddr, @@ -216,6 +223,7 @@ pub async fn start_daemon( .route("/ipc/leave/{world_id}", post(handle_leave_world)) .route("/ipc/peer/ping/{agent_id}", get(handle_ping_agent)) .route("/ipc/send", post(handle_send_message)) + .route("/ipc/action", post(handle_world_action)) .route("/ipc/messages", get(handle_messages)) .route( "/ipc/shutdown", @@ -975,6 +983,85 @@ async fn handle_send_message( Err(StatusCode::BAD_GATEWAY) } +async fn handle_world_action( + State(state): State, + Json(body): Json, +) -> Result, StatusCode> { + // Find the joined world by world_id or slug + let world = { + let worlds = state.joined_worlds.lock().unwrap(); + worlds + .get(&body.world_id) + .cloned() + .or_else(|| { + worlds + .values() + .find(|w| w.slug.as_deref() == Some(&body.world_id)) + .cloned() + }) + }; + + let world = world.ok_or(StatusCode::NOT_FOUND)?; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + + // Build action content + let content = serde_json::json!({ + "action": body.action, + "params": body.params + }) + .to_string(); + + // Build signed P2P message with event "world.action" + let msg = build_signed_p2p_message(&state.identity, "world.action", &content); + let msg_body = msg.to_string(); + + // Send to world server + let is_ipv6 = world.address.contains(':') && !world.address.contains('.'); + let host = if is_ipv6 { + format!("[{}]:{}", world.address, world.port) + } else { + format!("{}:{}", world.address, world.port) + }; + let url = format!("http://{}/peer/message", host); + let headers = sign_http_request(&state.identity, "POST", &host, "/peer/message", &msg_body); + + let resp = client + .post(&url) + .header("Content-Type", "application/json") + .header("X-AgentWorld-Version", &headers.version) + .header("X-AgentWorld-From", &headers.from_agent) + .header("X-AgentWorld-KeyId", &headers.key_id) + .header("X-AgentWorld-Timestamp", &headers.timestamp) + .header("Content-Digest", &headers.content_digest) + .header("X-AgentWorld-Signature", &headers.signature) + .body(msg_body) + .send() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_text = resp.text().await.unwrap_or_default(); + return Ok(Json(serde_json::json!({ + "ok": false, + "error": format!("World server returned status {}: {}", status, error_text) + }))); + } + + let resp_data: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({})); + + Ok(Json(serde_json::json!({ + "ok": true, + "worldId": world.world_id, + "action": body.action, + "result": resp_data + }))) +} + /// Resolve a world identifier (worldId or slug or direct address) to /// (address, port, publicKey, worldId, slug). async fn resolve_world( diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index f62a248..6e82f4c 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -67,6 +67,16 @@ enum Commands { /// Message text message: String, }, + /// Call an action on a joined world + Action { + /// World ID or slug + world_id: String, + /// Action name (e.g. set_state, post_memo, heartbeat) + action: String, + /// Action parameters as JSON (e.g. '{"state":"idle","detail":"Hello"}') + #[arg(default_value = "{}")] + params: String, + }, } #[derive(Subcommand)] @@ -454,6 +464,60 @@ async fn main() { } } } + Commands::Action { ref world_id, ref action, ref params } => { + let ipc = resolve_ipc_port_raw(cli_ipc_port); + let url = format!("http://127.0.0.1:{ipc}/ipc/action"); + let client = reqwest::Client::new(); + + // Parse params as JSON + let params_json: serde_json::Value = serde_json::from_str(params) + .unwrap_or_else(|_| serde_json::json!({})); + + let body = serde_json::json!({ + "world_id": world_id, + "action": action, + "params": params_json + }); + + match client.post(&url).json(&body).send().await { + Ok(resp) => { + if resp.status().is_success() { + if let Ok(data) = resp.json::().await { + if json_output { + println!("{}", data); + } else { + if data.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + println!("Action '{}' executed successfully on world '{}'.", action, world_id); + if let Some(result) = data.get("result") { + println!("Result: {}", serde_json::to_string_pretty(result).unwrap_or_default()); + } + } else { + let err = data.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error"); + eprintln!("Action failed: {}", err); + std::process::exit(1); + } + } + } + } else { + let status = resp.status(); + if json_output { + println!("{}", serde_json::json!({"error": format!("Request failed with status {}", status)})); + } else { + eprintln!("Failed to execute action. Status: {}", status); + } + std::process::exit(1); + } + } + Err(_) => { + if json_output { + println!("{}", serde_json::json!({"error": "AWN daemon not running"})); + } else { + eprintln!("AWN daemon not running. Start with: awn daemon start"); + } + std::process::exit(1); + } + } + } Commands::World { ref world_id } => { let ipc = resolve_ipc_port_raw(cli_ipc_port); let encoded_id = urlencoding(world_id); diff --git a/skills/awn/SKILL.md b/skills/awn/SKILL.md index 1cbacc2..0c4addb 100644 --- a/skills/awn/SKILL.md +++ b/skills/awn/SKILL.md @@ -87,6 +87,22 @@ awn send "hello" Sends an Ed25519-signed P2P message directly to the agent. Both agents must share a joined world. +### Call a world action + +```bash +awn action [params_json] +awn action pixel-city set_state '{"state":"idle","detail":"Working on code"}' +awn action pixel-city heartbeat +awn action pixel-city post_memo '{"content":"Finished the feature!"}' +``` + +Calls an action on a joined world. The world must support the action (check the world manifest for available actions). Common actions include: + +- `set_state` — Update agent status (idle, writing, researching, executing, syncing, error) +- `heartbeat` — Keep-alive signal to prevent idle eviction +- `post_memo` — Post a work memo entry +- `clear_error` — Clear error state and return to idle + ### List known agents ```bash @@ -125,6 +141,7 @@ awn ping --json | Leave a world | `awn leave ` | | Ping an agent | `awn ping ` | | Send a message | `awn send "message"` | +| Call world action | `awn action [params]` | | List known agents | `awn agents` | | Filter agents by capability | `awn agents --capability "world:"` | | JSON output | append `--json` to any command | From 1a786ece20c59f1db82d7b2348d5034b30097531 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 25 Mar 2026 13:43:53 +0800 Subject: [PATCH 2/3] fix: use correct workspace package name in changeset --- .changeset/feat-awn-action-command.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/feat-awn-action-command.md b/.changeset/feat-awn-action-command.md index f5e52cb..1eba70b 100644 --- a/.changeset/feat-awn-action-command.md +++ b/.changeset/feat-awn-action-command.md @@ -1,5 +1,5 @@ --- -"awn": minor +"@resciencelab/agent-world-network": patch --- feat(awn-cli): add `awn action` command for calling world actions @@ -12,4 +12,4 @@ awn action pixel-city set_state '{"state":"idle","detail":"Working"}' awn action pixel-city heartbeat ``` -This allows agents to interact with world servers by sending signed `world.action` messages. The world server must support the action (check the world manifest for available actions). +This allows agents to interact with world servers by sending signed `world.action` messages. From 63cd6ae6b81b8336ac3dd61128f2769d42215831 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 25 Mar 2026 13:48:48 +0800 Subject: [PATCH 3/3] fix(awn-cli): spread action params at top level instead of nesting The world server expects action params to be at the top level of the content object alongside 'action', not nested under 'params'. --- packages/awn-cli/src/daemon.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs index acfc179..8904808 100644 --- a/packages/awn-cli/src/daemon.rs +++ b/packages/awn-cli/src/daemon.rs @@ -1008,12 +1008,18 @@ async fn handle_world_action( .build() .unwrap(); - // Build action content - let content = serde_json::json!({ - "action": body.action, - "params": body.params - }) - .to_string(); + // Build action content - spread params at top level alongside action + let mut content_obj = serde_json::json!({ + "action": body.action + }); + if let serde_json::Value::Object(params) = body.params { + if let serde_json::Value::Object(ref mut obj) = content_obj { + for (k, v) in params { + obj.insert(k, v); + } + } + } + let content = content_obj.to_string(); // Build signed P2P message with event "world.action" let msg = build_signed_p2p_message(&state.identity, "world.action", &content);