Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/feat-awn-action-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@resciencelab/agent-world-network": patch
---

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 <world_id> <action_name> [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.
93 changes: 93 additions & 0 deletions packages/awn-cli/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -975,6 +983,91 @@ async fn handle_send_message(
Err(StatusCode::BAD_GATEWAY)
}

async fn handle_world_action(
State(state): State<DaemonState>,
Json(body): Json<WorldActionBody>,
) -> Result<Json<serde_json::Value>, 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 - 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);
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(
Expand Down
64 changes: 64 additions & 0 deletions packages/awn-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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::<serde_json::Value>().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);
Expand Down
17 changes: 17 additions & 0 deletions skills/awn/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ awn send <agent_id> "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 <world_id> <action_name> [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
Expand Down Expand Up @@ -125,6 +141,7 @@ awn ping <agent_id> --json
| Leave a world | `awn leave <world_id>` |
| Ping an agent | `awn ping <agent_id>` |
| Send a message | `awn send <agent_id> "message"` |
| Call world action | `awn action <world_id> <action> [params]` |
| List known agents | `awn agents` |
| Filter agents by capability | `awn agents --capability "world:"` |
| JSON output | append `--json` to any command |
Expand Down