From 259765a2152189cfa657dc2014dfd2506a8ae8fc Mon Sep 17 00:00:00 2001
From: Bartok9 <danielrpike9@users.noreply.github.com>
Date: Sat, 25 Jul 2026 23:37:39 -0400
Subject: [PATCH 1/3] feat(cli): add `buzz users me` for external-agent
 identity (#2663)

OpenClaw/external bridges hold BUZZ_PRIVATE_KEY but had no first-class
self-identity command short of embedding secp256k1. Print hex pubkey +
npub locally with no relay I/O (alias: whoami).

Signed-off-by: Bartok9 <danielrpike9@gmail.com>
---
 crates/buzz-cli/src/commands/users.rs | 48 +++++++++++++++++++++++++++
 crates/buzz-cli/src/lib.rs            |  6 ++++
 2 files changed, 54 insertions(+)

diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs
index 7c15d285a0..792891742c 100644
--- a/crates/buzz-cli/src/commands/users.rs
+++ b/crates/buzz-cli/src/commands/users.rs
@@ -4,9 +4,34 @@ use nostr::PublicKey;
 use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
 use crate::error::CliError;
 use crate::validate::validate_hex64;
+use nostr::ToBech32;
 
 // TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder
 
+/// Build identity JSON for `buzz users me` (unit-testable without a live client).
+pub(crate) fn me_identity_json(pubkey_hex: &str, npub_bech32: &str) -> serde_json::Value {
+    serde_json::json!({
+        "pubkey": pubkey_hex,
+        "npub": npub_bech32,
+    })
+}
+
+/// Print the active CLI identity (hex pubkey + npub) from the loaded private key.
+///
+/// No relay I/O — agents can resolve "who am I?" before connecting. Refs #2663.
+pub async fn cmd_me(
+    client: &BuzzClient,
+    _format: &crate::OutputFormat,
+) -> Result<(), CliError> {
+    let pk = client.keys().public_key();
+    let hex = pk.to_hex();
+    let npub = pk
+        .to_bech32()
+        .map_err(|e| CliError::Other(format!("npub encode failed: {e}")))?;
+    println!("{}", me_identity_json(&hex, &npub));
+    Ok(())
+}
+
 /// Get user profiles (kind:0 metadata events).
 ///
 /// - 0 pubkeys, no name → query our own profile
@@ -536,6 +561,7 @@ pub async fn dispatch(
 ) -> Result<(), CliError> {
     use crate::UsersCmd;
     match cmd {
+        UsersCmd::Me => cmd_me(client, format).await,
         UsersCmd::Get {
             pubkeys,
             name,
@@ -762,4 +788,26 @@ mod tests {
         let event = json!({"pubkey": "user", "tags": [["p"]]});
         assert_eq!(presence_subject(&event), "user");
     }
+
+    #[test]
+    fn me_identity_json_includes_pubkey_and_npub() {
+        let hex = "a".repeat(64);
+        let v = super::me_identity_json(
+            &hex,
+            "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5s0xov",
+        );
+        assert_eq!(v["pubkey"], hex);
+        assert!(v["npub"].as_str().unwrap().starts_with("npub1"));
+    }
+
+    #[test]
+    fn me_identity_from_generated_keys_roundtrips_bech32() {
+        use nostr::{Keys, ToBech32};
+        let keys = Keys::generate();
+        let hex = keys.public_key().to_hex();
+        let npub = keys.public_key().to_bech32().expect("npub");
+        let v = super::me_identity_json(&hex, &npub);
+        assert_eq!(v["pubkey"].as_str().unwrap().len(), 64);
+        assert_eq!(v["npub"].as_str().unwrap(), npub);
+    }
 }
diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs
index 0726406d29..472c5f70c4 100644
--- a/crates/buzz-cli/src/lib.rs
+++ b/crates/buzz-cli/src/lib.rs
@@ -803,6 +803,12 @@ pub enum DmsCmd {
 
 #[derive(Subcommand)]
 pub enum UsersCmd {
+    /// Print the CLI identity derived from BUZZ_PRIVATE_KEY (no relay round-trip)
+    ///
+    /// External agents (OpenClaw, custom bridges) hold a private key but often
+    /// need their own pubkey without embedding secp256k1. Refs #2663 gap #1.
+    #[command(name = "me", alias = "whoami")]
+    Me,
     /// Look up user profiles by pubkey or name
     Get {
         /// User pubkey(s) to look up (64-char hex). Omit for your own profile

From 4c1dd8c65cf76ce2d0d4db74efbdcf6a2c98f450 Mon Sep 17 00:00:00 2001
From: Bartok9 <danielrpike9@users.noreply.github.com>
Date: Sun, 26 Jul 2026 00:27:23 -0400
Subject: [PATCH 2/3] feat(cli): external-agent realtime path for #2663

Add buzz listen (WS NDJSON + optional webhook), keep pubkey/tags in
messages get --format compact, headless agents publish for kind:30177
with client-side schema validation, and docs/cli-external-agents.md.
Includes users me for a self-contained E2E path (sibling #2933).

Signed-off-by: Bartok <danielrpike9@gmail.com>
Signed-off-by: Bartok9 <danielrpike9@gmail.com>
---
 crates/buzz-cli/Cargo.toml                   |   2 +-
 crates/buzz-cli/src/client.rs                |   5 +
 crates/buzz-cli/src/commands/agents.rs       |  23 ++
 crates/buzz-cli/src/commands/listen.rs       | 279 +++++++++++++++++++
 crates/buzz-cli/src/commands/messages.rs     |  42 ++-
 crates/buzz-cli/src/commands/mod.rs          |   1 +
 crates/buzz-cli/src/lib.rs                   |  68 ++++-
 crates/buzz-cli/src/managed_agent_publish.rs | 219 +++++++++++++++
 docs/cli-external-agents.md                  | 106 +++++++
 9 files changed, 739 insertions(+), 6 deletions(-)
 create mode 100644 crates/buzz-cli/src/commands/listen.rs
 create mode 100644 crates/buzz-cli/src/managed_agent_publish.rs
 create mode 100644 docs/cli-external-agents.md

diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml
index 1476e60bfd..4e4f2d53ac 100644
--- a/crates/buzz-cli/Cargo.toml
+++ b/crates/buzz-cli/Cargo.toml
@@ -23,7 +23,7 @@ clap = { version = "4", features = ["derive", "env"] }
 reqwest = { workspace = true, features = ["json"] }
 
 # Async runtime — tokio macros + multi-thread for reqwest
-tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
 
 # Serialization — JSON body building and response passthrough
 serde = { workspace = true }
diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs
index d0dd2677a9..481bcdd4ab 100644
--- a/crates/buzz-cli/src/client.rs
+++ b/crates/buzz-cli/src/client.rs
@@ -569,6 +569,11 @@ impl BuzzClient {
         &self.relay_url
     }
 
+    /// NIP-OA auth tag for WebSocket AUTH / EVENT signing, if configured.
+    pub fn auth_tag(&self) -> Option<&nostr::Tag> {
+        self.auth_tag.as_ref()
+    }
+
     /// Return the owner pubkey carried by the NIP-OA auth tag, if any.
     ///
     /// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the
diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs
index 58564a45c2..eba5419072 100644
--- a/crates/buzz-cli/src/commands/agents.rs
+++ b/crates/buzz-cli/src/commands/agents.rs
@@ -147,6 +147,29 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
             Ok(())
         }
 
+        AgentsCmd::Publish {
+            agent_pubkey,
+            content,
+        } => {
+            let raw = crate::validate::read_or_stdin(&content)?;
+            let body = crate::managed_agent_publish::validate_managed_agent_content(&raw)?;
+            let builder =
+                crate::managed_agent_publish::build_managed_agent_event(&agent_pubkey, &body)?;
+            let event = client.sign_event(builder)?;
+            let event_id = event.id.to_hex();
+            client.submit_event(event).await?;
+            println!(
+                "{}",
+                serde_json::json!({
+                    "ok": true,
+                    "event_id": event_id,
+                    "kind": 30177,
+                    "d": agent_pubkey,
+                    "action": "publish",
+                })
+            );
+            Ok(())
+        }
         AgentsCmd::Archived => cmd_archived(client).await,
     }
 }
diff --git a/crates/buzz-cli/src/commands/listen.rs b/crates/buzz-cli/src/commands/listen.rs
new file mode 100644
index 0000000000..52fc1f1a92
--- /dev/null
+++ b/crates/buzz-cli/src/commands/listen.rs
@@ -0,0 +1,279 @@
+//! `buzz listen` — persistent WebSocket stream of channel events (#2663 Option B).
+//!
+//! Prints one normalized JSON object per inbound EVENT matching the subscription
+//! filters (newline-delimited JSON on stdout). Uses the same NIP-42 / NIP-OA
+//! path as `publish_ephemeral_event`.
+
+use std::io::Write;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use serde_json::json;
+use uuid::Uuid;
+
+use crate::client::{normalize_events, BuzzClient};
+use crate::error::CliError;
+use crate::validate::validate_uuid;
+
+/// Default kinds for channel traffic (matches `messages get` defaults).
+const DEFAULT_KINDS: &[u32] = &[9, 40002, 40008, 45001, 45003];
+
+fn parse_kinds(raw: Option<&str>) -> Result<Vec<u32>, CliError> {
+    match raw {
+        None => Ok(DEFAULT_KINDS.to_vec()),
+        Some(s) if s.trim().is_empty() => Ok(DEFAULT_KINDS.to_vec()),
+        Some(s) => {
+            let mut out = Vec::new();
+            for part in s.split(',') {
+                let part = part.trim();
+                if part.is_empty() {
+                    continue;
+                }
+                let k: u32 = part
+                    .parse()
+                    .map_err(|_| CliError::Usage(format!("invalid kind in --kinds: {part}")))?;
+                out.push(k);
+            }
+            if out.is_empty() {
+                return Err(CliError::Usage("--kinds must list at least one kind".into()));
+            }
+            Ok(out)
+        }
+    }
+}
+
+/// Build subscription filter list for REQ.
+pub(crate) fn build_listen_filters(
+    channels: &[String],
+    mentions_of_me: bool,
+    my_pubkey_hex: &str,
+    kinds: &[u32],
+) -> Result<Vec<serde_json::Value>, CliError> {
+    if channels.is_empty() && !mentions_of_me {
+        return Err(CliError::Usage(
+            "buzz listen requires --channel <UUID> and/or --mentions-of-me".into(),
+        ));
+    }
+    for ch in channels {
+        validate_uuid(ch)?;
+    }
+
+    let mut filters = Vec::new();
+    if !channels.is_empty() {
+        filters.push(json!({
+            "kinds": kinds,
+            "#h": channels,
+        }));
+    }
+    if mentions_of_me {
+        let mut f = json!({
+            "kinds": kinds,
+            "#p": [my_pubkey_hex],
+        });
+        if !channels.is_empty() {
+            f["#h"] = json!(channels);
+        }
+        filters.push(f);
+    }
+    Ok(filters)
+}
+
+/// Optional POST of each event JSON body to `url` (Option A, best-effort).
+async fn post_webhook(client: &reqwest::Client, url: &str, body: &str) {
+    if let Err(e) = client
+        .post(url)
+        .header("content-type", "application/json")
+        .body(body.to_string())
+        .send()
+        .await
+    {
+        eprintln!(
+            "{}",
+            json!({"error":"webhook","message": format!("{e}")})
+        );
+    }
+}
+
+fn http_to_ws(http_url: &str) -> String {
+    http_url
+        .replace("https://", "wss://")
+        .replace("http://", "ws://")
+}
+
+/// Run the listen loop until Ctrl-C or fatal error.
+pub async fn cmd_listen(
+    client: &BuzzClient,
+    channels: Vec<String>,
+    mentions_of_me: bool,
+    kinds_raw: Option<String>,
+    webhook: Option<String>,
+    reconnect: bool,
+) -> Result<(), CliError> {
+    let kinds = parse_kinds(kinds_raw.as_deref())?;
+    let my_pk = client.keys().public_key().to_hex();
+    let filters = build_listen_filters(&channels, mentions_of_me, &my_pk, &kinds)?;
+    let ws_url = http_to_ws(client.relay_url());
+    let http = reqwest::Client::new();
+    let running = Arc::new(AtomicBool::new(true));
+
+    // Background Ctrl-C watcher (unix + windows via tokio::signal).
+    {
+        let r = running.clone();
+        tokio::spawn(async move {
+            let _ = tokio::signal::ctrl_c().await;
+            r.store(false, Ordering::SeqCst);
+        });
+    }
+
+    let mut backoff_ms: u64 = 500;
+    const MAX_BACKOFF_MS: u64 = 30_000;
+
+    while running.load(Ordering::SeqCst) {
+        match listen_session(
+            client,
+            &ws_url,
+            &filters,
+            webhook.as_deref(),
+            &http,
+            running.clone(),
+        )
+        .await
+        {
+            Ok(()) => {
+                if !running.load(Ordering::SeqCst) {
+                    break;
+                }
+                if !reconnect {
+                    break;
+                }
+            }
+            Err(e) => {
+                if !running.load(Ordering::SeqCst) {
+                    break;
+                }
+                if !reconnect {
+                    return Err(e);
+                }
+                eprintln!(
+                    "{}",
+                    json!({
+                        "error": "listen_reconnect",
+                        "message": e.to_string(),
+                        "backoff_ms": backoff_ms,
+                    })
+                );
+            }
+        }
+        if !reconnect || !running.load(Ordering::SeqCst) {
+            break;
+        }
+        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
+        backoff_ms = (backoff_ms.saturating_mul(2)).min(MAX_BACKOFF_MS);
+    }
+    Ok(())
+}
+
+async fn listen_session(
+    client: &BuzzClient,
+    ws_url: &str,
+    filters: &[serde_json::Value],
+    webhook: Option<&str>,
+    http: &reqwest::Client,
+    running: Arc<AtomicBool>,
+) -> Result<(), CliError> {
+    use buzz_ws_client::{NostrWsConnection, RelayMessage};
+
+    let mut conn = NostrWsConnection::connect_authenticated(
+        ws_url,
+        client.keys(),
+        client.auth_tag(),
+    )
+    .await
+    .map_err(|e| CliError::Other(format!("websocket: {e}")))?;
+
+    let sub_id = format!("buzz-listen-{}", &Uuid::new_v4().to_string()[..8]);
+    let mut req = vec![json!("REQ"), json!(sub_id)];
+    for f in filters {
+        req.push(f.clone());
+    }
+    conn.send_raw(&json!(req))
+        .await
+        .map_err(|e| CliError::Other(format!("websocket send: {e}")))?;
+
+    while running.load(Ordering::SeqCst) {
+        // Short poll so Ctrl-C can exit promptly.
+        let msg = match conn.next_event(Duration::from_millis(500)).await {
+            Ok(m) => m,
+            Err(buzz_ws_client::WsClientError::Timeout) => continue,
+            Err(e) => return Err(CliError::Other(format!("websocket: {e}"))),
+        };
+
+        match msg {
+            RelayMessage::Event { event, .. } => {
+                let raw = serde_json::to_value(event.as_ref())
+                    .map_err(|e| CliError::Other(format!("event serialize: {e}")))?;
+                let line = normalize_events(std::slice::from_ref(&raw));
+                // normalize_events returns a JSON array; unwrap first element for NDJSON stream.
+                let arr: Vec<serde_json::Value> =
+                    serde_json::from_str(&line).unwrap_or_default();
+                let body = arr.first().cloned().unwrap_or(raw);
+                let text = serde_json::to_string(&body).unwrap_or_default();
+                println!("{text}");
+                let _ = std::io::stdout().flush();
+                if let Some(url) = webhook {
+                    post_webhook(http, url, &text).await;
+                }
+            }
+            RelayMessage::Closed { message, .. } => {
+                return Err(CliError::Other(format!("subscription closed: {message}")));
+            }
+            RelayMessage::Notice { message } => {
+                eprintln!("{}", json!({"notice": message}));
+            }
+            RelayMessage::Eose { .. }
+            | RelayMessage::Ok(_)
+            | RelayMessage::Auth { .. }
+            | RelayMessage::Count { .. } => {}
+        }
+    }
+
+    let _ = conn.send_raw(&json!(["CLOSE", sub_id])).await;
+    let _ = conn.disconnect().await;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn requires_channel_or_mentions() {
+        let err = build_listen_filters(&[], false, &"a".repeat(64), DEFAULT_KINDS).unwrap_err();
+        assert!(matches!(err, CliError::Usage(_)));
+    }
+
+    #[test]
+    fn channel_filter_uses_h_tag() {
+        let ch = "11111111-1111-1111-1111-111111111111".to_string();
+        let f = build_listen_filters(&[ch.clone()], false, &"a".repeat(64), DEFAULT_KINDS).unwrap();
+        assert_eq!(f.len(), 1);
+        assert_eq!(f[0]["#h"][0], ch);
+    }
+
+    #[test]
+    fn mentions_filter_uses_p_tag() {
+        let pk = "b".repeat(64);
+        let f = build_listen_filters(&[], true, &pk, DEFAULT_KINDS).unwrap();
+        assert_eq!(f.len(), 1);
+        assert_eq!(f[0]["#p"][0], pk);
+    }
+
+    #[test]
+    fn both_channel_and_mentions_two_filters() {
+        let ch = "11111111-1111-1111-1111-111111111111".to_string();
+        let pk = "c".repeat(64);
+        let f = build_listen_filters(&[ch], true, &pk, DEFAULT_KINDS).unwrap();
+        assert_eq!(f.len(), 2);
+    }
+}
diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs
index 40a9ae80b5..0e5bca44d1 100644
--- a/crates/buzz-cli/src/commands/messages.rs
+++ b/crates/buzz-cli/src/commands/messages.rs
@@ -334,13 +334,17 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
         crate::OutputFormat::Compact => {
             let events: Vec<serde_json::Value> =
                 serde_json::from_str(normalized).unwrap_or_default();
+            // Compact is for agent scanning (#2663 gap #2): keep pubkey + tags so
+            // listeners can skip self-messages and detect `p` mention tags.
             let compact: Vec<serde_json::Value> = events
                 .iter()
                 .map(|e| {
                     serde_json::json!({
-                        "id": e.get("id").cloned().unwrap_or_default(),
-                        "content": e.get("content").cloned().unwrap_or_default(),
-                        "created_at": e.get("created_at").cloned().unwrap_or_default(),
+                        "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
+                        "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
+                        "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
+                        "created_at": e.get("created_at").cloned().unwrap_or(serde_json::json!(0)),
+                        "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
                     })
                 })
                 .collect();
@@ -1373,3 +1377,35 @@ mod tests {
         assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1);
     }
 }
+
+
+#[cfg(test)]
+mod compact_format_tests {
+    use super::format_events;
+    use crate::OutputFormat;
+
+    #[test]
+    fn compact_format_keeps_pubkey_and_tags() {
+        let normalized = r#"[{"id":"aa","pubkey":"bb","kind":9,"content":"hi","created_at":1,"tags":[["p","cc"],["h","ch"]]}]"#;
+        let out = format_events(normalized, &OutputFormat::Compact);
+        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
+        let row = &v[0];
+        assert_eq!(row["id"], "aa");
+        assert_eq!(row["pubkey"], "bb");
+        assert_eq!(row["content"], "hi");
+        assert_eq!(row["created_at"], 1);
+        assert_eq!(row["tags"][0][0], "p");
+        assert_eq!(row["tags"][0][1], "cc");
+        assert!(row.get("kind").is_none(), "compact omits kind");
+    }
+
+    #[test]
+    fn compact_format_defaults_missing_tags() {
+        let normalized = r#"[{"id":"aa","content":"x","created_at":2}]"#;
+        let out = format_events(normalized, &OutputFormat::Compact);
+        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
+        // Missing keys become JSON null via Value::default, tags become [].
+        assert_eq!(v[0]["pubkey"], "");
+        assert_eq!(v[0]["tags"], serde_json::json!([]));
+    }
+}
diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs
index 8691590636..1735fbd35e 100644
--- a/crates/buzz-cli/src/commands/mod.rs
+++ b/crates/buzz-cli/src/commands/mod.rs
@@ -5,6 +5,7 @@ pub mod dms;
 pub mod emoji;
 pub mod feed;
 pub mod issues;
+pub mod listen;
 pub mod mem;
 pub mod messages;
 pub mod moderation;
diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs
index 472c5f70c4..b259e64fd5 100644
--- a/crates/buzz-cli/src/lib.rs
+++ b/crates/buzz-cli/src/lib.rs
@@ -2,6 +2,7 @@ pub mod agent_management;
 mod client;
 mod commands;
 mod error;
+mod managed_agent_publish;
 mod validate;
 
 use clap::{Parser, Subcommand};
@@ -173,6 +174,33 @@ pub enum OutputFormat {
 
 #[derive(Subcommand)]
 enum Cmd {
+    /// Stream channel events over WebSocket (NDJSON on stdout) — external agents (#2663)
+    #[command(
+        after_help = "Requires BUZZ_PRIVATE_KEY and BUZZ_RELAY_URL. NIP-42 AUTH over WS.
+
+Filters (need at least one): --channel UUID (repeatable), --mentions-of-me.
+Optional: --kinds, --webhook URL, --no-reconnect.
+
+OpenClaw: buzz listen --channel $CHANNEL --mentions-of-me | jq -c .
+Docs: docs/cli-external-agents.md (30177 schema + compact fields)."
+    )]
+    Listen {
+        /// Channel UUID to subscribe (repeatable)
+        #[arg(long = "channel")]
+        channels: Vec<String>,
+        /// Subscribe to events that p-tag this identity
+        #[arg(long, default_value_t = false)]
+        mentions_of_me: bool,
+        /// Comma-separated Nostr kinds (default: messages get defaults)
+        #[arg(long)]
+        kinds: Option<String>,
+        /// Optional webhook URL — POST each event JSON body
+        #[arg(long)]
+        webhook: Option<String>,
+        /// Disable automatic reconnect with exponential backoff
+        #[arg(long, default_value_t = false)]
+        no_reconnect: bool,
+    },
     /// Draft owner-reviewed agent creation and updates
     #[command(subcommand)]
     Agents(AgentsCmd),
@@ -242,6 +270,8 @@ enum Cmd {
 pub enum RespondToArg {
     #[value(name = "owner-only")]
     OwnerOnly,
+    #[value(name = "allowlist")]
+    Allowlist,
     #[value(name = "anyone")]
     Anyone,
 }
@@ -250,6 +280,7 @@ impl RespondToArg {
     fn to_wire(self) -> String {
         match self {
             Self::OwnerOnly => "owner-only",
+            Self::Allowlist => "allowlist",
             Self::Anyone => "anyone",
         }
         .to_string()
@@ -332,6 +363,19 @@ buzz agents unarchive <PUBKEY> --reason returned")]
         #[arg(long, default_value = "")]
         content: String,
     },
+    /// Headless publish kind:30177 managed-agent definition (directory registration)
+    #[command(
+        name = "publish",
+        after_help = "Signs and POSTs a NIP-33 parameterized replaceable kind:30177 as the CLI\nidentity (owner). d-tag = agent pubkey. Content MUST include name, parallelism,\nand respond_to (owner-only|allowlist|anyone). Relay does not validate content —\nthis command does client-side so the desktop directory can parse the entry.\n\nExample:\n  buzz agents publish --agent-pubkey <64hex> --content '{\"name\":\"OpenClaw\",\"parallelism\":1,\"respond_to\":\"owner-only\"}'\n  echo '{...}' | buzz agents publish --agent-pubkey <64hex> --content -\n\nSchema: docs/cli-external-agents.md"
+    )]
+    Publish {
+        /// Agent identity pubkey (64-hex) — d-tag of the 30177
+        #[arg(long)]
+        agent_pubkey: String,
+        /// JSON content body or '-' for stdin
+        #[arg(long)]
+        content: String,
+    },
     /// Read the relay's current NIP-IA archive snapshot (kind 13535)
     #[command(
         after_help = "Verifies the snapshot's NIP-11 `self` authorship, event id, signature, \
@@ -1812,6 +1856,23 @@ async fn run(cli: Cli) -> Result<(), CliError> {
     let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
 
     match cli.command {
+        Cmd::Listen {
+            channels,
+            mentions_of_me,
+            kinds,
+            webhook,
+            no_reconnect,
+        } => {
+            commands::listen::cmd_listen(
+                &client,
+                channels,
+                mentions_of_me,
+                kinds,
+                webhook,
+                !no_reconnect,
+            )
+            .await
+        }
         Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await,
         Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
         Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
@@ -1881,6 +1942,7 @@ mod tests {
             "emoji",
             "feed",
             "issues",
+            "listen",
             "media",
             "mem",
             "messages",
@@ -1943,6 +2005,7 @@ mod tests {
                 "archived",
                 "draft-create",
                 "draft-update",
+                "publish",
                 "unarchive"
             ]
         );
@@ -1994,6 +2057,7 @@ mod tests {
             names(&cmd, "users"),
             vec![
                 "get",
+                "me",
                 "presence",
                 "set-presence",
                 "set-profile",
@@ -2069,7 +2133,7 @@ mod tests {
     #[test]
     fn subcommand_counts_are_stable() {
         let expected: Vec<(&str, usize)> = vec![
-            ("agents", 5),
+            ("agents", 6),
             ("canvas", 2),
             ("channels", 16),
             ("dms", 4),
@@ -2085,7 +2149,7 @@ mod tests {
             ("repos", 5),
             ("social", 7),
             ("upload", 1),
-            ("users", 5),
+            ("users", 6),
             ("workflows", 8),
         ];
 
diff --git a/crates/buzz-cli/src/managed_agent_publish.rs b/crates/buzz-cli/src/managed_agent_publish.rs
new file mode 100644
index 0000000000..7a1d1fdca2
--- /dev/null
+++ b/crates/buzz-cli/src/managed_agent_publish.rs
@@ -0,0 +1,219 @@
+//! Headless kind:30177 managed-agent directory publish (#2663 gaps #3–#4).
+//!
+//! Content schema mirrors desktop `ManagedAgentEventContent` (opt-in public
+//! fields only). Sign as the **owner**; `d` tag = agent pubkey.
+
+use nostr::{EventBuilder, Kind, PublicKey, Tag};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::error::CliError;
+use crate::validate::validate_hex64;
+use buzz_core::kind::KIND_MANAGED_AGENT;
+
+/// Wire form of kind:30177 content (required: name, parallelism, respond_to).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ManagedAgentPublishContent {
+    pub name: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub persona_id: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub system_prompt: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub model: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub provider: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub persona_source_version: Option<String>,
+    pub parallelism: u32,
+    /// kebab-case: owner-only | allowlist | anyone
+    pub respond_to: String,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub respond_to_allowlist: Vec<String>,
+}
+
+/// Validate and normalize pasteable JSON for kind:30177 content.
+pub fn validate_managed_agent_content(raw: &str) -> Result<ManagedAgentPublishContent, CliError> {
+    let v: Value = serde_json::from_str(raw)
+        .map_err(|e| CliError::Usage(format!("invalid 30177 JSON: {e}")))?;
+    let obj = v
+        .as_object()
+        .ok_or_else(|| CliError::Usage("30177 content must be a JSON object".into()))?;
+
+    // Reject known-secret / local-only keys so operators don't leak keys.
+    const FORBIDDEN: &[&str] = &[
+        "private_key_nsec",
+        "private_key",
+        "auth_tag",
+        "env_vars",
+        "backend",
+        "nsec",
+    ];
+    for k in FORBIDDEN {
+        if obj.contains_key(*k) {
+            return Err(CliError::Usage(format!(
+                "30177 content must not include '{k}' (secrets/local fields stay off-wire)"
+            )));
+        }
+    }
+
+    let name = obj
+        .get("name")
+        .and_then(|x| x.as_str())
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+        .ok_or_else(|| {
+            CliError::Usage(
+                "30177 content requires non-empty string field `name` (not display_name)".into(),
+            )
+        })?
+        .to_string();
+
+    let parallelism = obj
+        .get("parallelism")
+        .and_then(|x| x.as_u64())
+        .ok_or_else(|| {
+            CliError::Usage("30177 content requires unsigned integer field `parallelism`".into())
+        })?;
+    if parallelism == 0 || parallelism > 1024 {
+        return Err(CliError::Usage(
+            "30177 `parallelism` must be between 1 and 1024".into(),
+        ));
+    }
+
+    let respond_to = obj
+        .get("respond_to")
+        .and_then(|x| x.as_str())
+        .ok_or_else(|| {
+            CliError::Usage(
+                "30177 content requires `respond_to` (owner-only|allowlist|anyone)".into(),
+            )
+        })?;
+    let respond_to = match respond_to {
+        "owner-only" | "allowlist" | "anyone" => respond_to.to_string(),
+        other => {
+            return Err(CliError::Usage(format!(
+                "invalid respond_to '{other}' (expected owner-only|allowlist|anyone)"
+            )))
+        }
+    };
+
+    let mut allowlist = Vec::new();
+    if let Some(arr) = obj.get("respond_to_allowlist") {
+        let Some(list) = arr.as_array() else {
+            return Err(CliError::Usage(
+                "`respond_to_allowlist` must be an array of hex pubkeys".into(),
+            ));
+        };
+        for entry in list {
+            let s = entry
+                .as_str()
+                .ok_or_else(|| CliError::Usage("allowlist entries must be strings".into()))?;
+            validate_hex64(s)?;
+            allowlist.push(s.to_ascii_lowercase());
+        }
+    }
+    if respond_to == "allowlist" && allowlist.is_empty() {
+        return Err(CliError::Usage(
+            "respond_to=allowlist requires non-empty respond_to_allowlist".into(),
+        ));
+    }
+
+    let opt_str = |key: &str| -> Result<Option<String>, CliError> {
+        match obj.get(key) {
+            None => Ok(None),
+            Some(Value::Null) => Ok(None),
+            Some(Value::String(s)) => Ok(Some(s.clone())),
+            Some(_) => Err(CliError::Usage(format!("`{key}` must be a string"))),
+        }
+    };
+
+    Ok(ManagedAgentPublishContent {
+        name,
+        persona_id: opt_str("persona_id")?,
+        system_prompt: opt_str("system_prompt")?,
+        model: opt_str("model")?,
+        provider: opt_str("provider")?,
+        persona_source_version: opt_str("persona_source_version")?,
+        parallelism: parallelism as u32,
+        respond_to,
+        respond_to_allowlist: allowlist,
+    })
+}
+
+pub fn build_managed_agent_event(
+    agent_pubkey_hex: &str,
+    content: &ManagedAgentPublishContent,
+) -> Result<EventBuilder, CliError> {
+    validate_hex64(agent_pubkey_hex)?;
+    // Ensure pubkey parses.
+    PublicKey::parse(agent_pubkey_hex)
+        .map_err(|e| CliError::Usage(format!("invalid agent pubkey: {e}")))?;
+    let body = serde_json::to_string(content)
+        .map_err(|e| CliError::Other(format!("serialize 30177: {e}")))?;
+    let d = Tag::parse(["d", agent_pubkey_hex])
+        .map_err(|e| CliError::Other(format!("d-tag: {e}")))?;
+    Ok(EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), body).tags([d]))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn minimal_ok() -> &'static str {
+        r#"{"name":"OpenClaw","parallelism":1,"respond_to":"owner-only"}"#
+    }
+
+    #[test]
+    fn accepts_minimal_schema() {
+        let c = validate_managed_agent_content(minimal_ok()).unwrap();
+        assert_eq!(c.name, "OpenClaw");
+        assert_eq!(c.parallelism, 1);
+        assert_eq!(c.respond_to, "owner-only");
+    }
+
+    #[test]
+    fn rejects_display_name_only() {
+        let err = validate_managed_agent_content(
+            r#"{"display_name":"x","parallelism":1,"respond_to":"anyone"}"#,
+        )
+        .unwrap_err();
+        assert!(matches!(err, CliError::Usage(_)));
+    }
+
+    #[test]
+    fn rejects_bad_respond_to() {
+        let err = validate_managed_agent_content(
+            r#"{"name":"x","parallelism":1,"respond_to":"everyone"}"#,
+        )
+        .unwrap_err();
+        assert!(matches!(err, CliError::Usage(_)));
+    }
+
+    #[test]
+    fn rejects_secrets() {
+        let err = validate_managed_agent_content(
+            r#"{"name":"x","parallelism":1,"respond_to":"anyone","private_key_nsec":"nsec1x"}"#,
+        )
+        .unwrap_err();
+        assert!(matches!(err, CliError::Usage(_)));
+    }
+
+    #[test]
+    fn allowlist_requires_entries() {
+        let err = validate_managed_agent_content(
+            r#"{"name":"x","parallelism":1,"respond_to":"allowlist"}"#,
+        )
+        .unwrap_err();
+        assert!(matches!(err, CliError::Usage(_)));
+    }
+
+    #[test]
+    fn builds_event_with_d_tag() {
+        let agent = "a".repeat(64);
+        let c = validate_managed_agent_content(minimal_ok()).unwrap();
+        let b = build_managed_agent_event(&agent, &c).unwrap();
+        // Builder existence is enough; full sign needs keys in integration.
+        let _ = b;
+    }
+}
diff --git a/docs/cli-external-agents.md b/docs/cli-external-agents.md
new file mode 100644
index 0000000000..28c1e49e3c
--- /dev/null
+++ b/docs/cli-external-agents.md
@@ -0,0 +1,106 @@
+# External agents (CLI) — OpenClaw & non-ACP bridges
+
+Issue: [#2663](https://github.com/block/buzz/issues/2663)
+
+This note documents the **headless CLI path** so an external agent (OpenClaw, custom
+daemon, etc.) can participate in realtime Buzz without Desktop draft forms or ACP.
+
+## Identity — `buzz users me`
+
+```bash
+export BUZZ_PRIVATE_KEY=nsec1…   # or 64-hex
+buzz users me
+# {"pubkey":"<64hex>","npub":"npub1…"}
+```
+
+No relay round-trip. Use the hex `pubkey` to skip self-messages and set `#p` filters.
+
+Sibling PR hardens this as a thin slice (#2933); it is also included on this branch
+so E2E docs stand alone.
+
+## Realtime receive — `buzz listen` (Option B)
+
+Persistent WebSocket (NIP-42 AUTH, optional `BUZZ_AUTH_TAG`) streaming **newline-delimited
+JSON** events to stdout.
+
+```bash
+export BUZZ_RELAY_URL=https://your.relay
+export BUZZ_PRIVATE_KEY=…
+
+ME=$(buzz users me | jq -r .pubkey)
+CHANNEL=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
+
+buzz listen --channel "$CHANNEL" --mentions-of-me | while IFS= read -r line; do
+  pk=$(jq -r .pubkey <<<"$line")
+  [[ "$pk" == "$ME" ]] && continue
+  # optional: require swagger p-tag mention
+  # jq -e --arg me "$ME" '.tags[]? | select(.[0]=="p" and .[1]==$me)' <<<"$line" >/dev/null || continue
+  content=$(jq -r .content <<<"$line")
+  id=$(jq -r .id <<<"$line")
+  # hand to OpenClaw / your brain, then:
+  # buzz messages send --channel "$CHANNEL" --content "$reply" --reply-to "$id"
+done
+```
+
+| Flag | Meaning |
+|------|---------|
+| `--channel UUID` | Repeatable. REQ filter `#h` = channel |
+| `--mentions-of-me` | REQ filter `#p` = CLI pubkey (optionally AND `#h` when channels set) |
+| `--kinds a,b,…` | Override default kinds (same as `messages get`) |
+| `--webhook URL` | Option A (optional): POST each event JSON body |
+| `--no-reconnect` | Exit on drop instead of exponential backoff |
+
+Graceful stop: Ctrl-C closes the subscription.
+
+## Poll path field gap — `messages get --format compact`
+
+Compact now **retains `pubkey` and `tags`** (plus `id`, `content`, `created_at`) so
+agents can skip self and detect `p` tags without full JSON.
+
+## Directory registration — `buzz agents publish` (kind:30177)
+
+Owner-signed NIP-33 parameterized replaceable event. **d-tag = agent pubkey.**
+The relay does **not** schema-validate content; the CLI does, so bad bodies never
+render silently empty in the desktop directory.
+
+### Required content fields
+
+| Field | Type | Notes |
+|-------|------|--------|
+| `name` | string | **Not** `display_name` |
+| `parallelism` | u32 | 1–1024 |
+| `respond_to` | string | kebab-case: `owner-only` \| `allowlist` \| `anyone` |
+
+### Optional / conditional
+
+| Field | Notes |
+|-------|--------|
+| `respond_to_allowlist` | string[] of 64-hex pubkeys; **required non-empty** when `respond_to` is `allowlist` |
+| `system_prompt`, `model`, `provider` | Definition-less instances |
+| `persona_id`, `persona_source_version` | Definition-linked / drift |
+
+### Forbidden on the wire
+
+`private_key_nsec`, `auth_tag`, `env_vars`, `backend`, and other secrets — rejected
+client-side if present in the JSON blob.
+
+```bash
+# Sign as the *owner* private key:
+buzz agents publish   --agent-pubkey "$AGENT_HEX"   --content '{"name":"OpenClaw","parallelism":1,"respond_to":"owner-only"}'
+```
+
+## Residuals (#2663 gap #5)
+
+@-mention **selector** eligibility for pure external agents (membership + kind:0 ⇒
+mentionable in Desktop) may still require Desktop work coordinated with open mention
+PRs (#2603 family). Relay membership + headless 30177 + realtime listen are the
+primary product path shipped here. Option C (generic ACP `--agent-command`) is owned
+by BYOH harness work (e.g. #2773) — not duplicated in this PR.
+
+## Env summary
+
+| Var | Role |
+|-----|------|
+| `BUZZ_PRIVATE_KEY` | Identity (required for relay cmds) |
+| `BUZZ_RELAY_URL` | HTTP(S) base; listens converts to ws/wss |
+| `BUZZ_AUTH_TAG` | Optional NIP-OA JSON for agent-attested identity |

From 0834a0bbe6eacf474449b6cdb1ab3624b0779904 Mon Sep 17 00:00:00 2001
From: Bartok9 <danielrpike9@users.noreply.github.com>
Date: Wed, 29 Jul 2026 14:37:39 -0400
Subject: [PATCH 3/3] fix(cli): rustfmt + clippy for external-agent listen path

Address CI Rust Lint (fmt) and Windows clippy cloned_ref_to_slice_refs
on buzz listen tests.

Signed-off-by: Bartok9 <danielrpike9@gmail.com>
---
 crates/buzz-cli/src/commands/listen.rs       | 31 ++++++++++----------
 crates/buzz-cli/src/commands/messages.rs     |  1 -
 crates/buzz-cli/src/commands/users.rs        |  5 +---
 crates/buzz-cli/src/managed_agent_publish.rs |  4 +--
 4 files changed, 19 insertions(+), 22 deletions(-)

diff --git a/crates/buzz-cli/src/commands/listen.rs b/crates/buzz-cli/src/commands/listen.rs
index 52fc1f1a92..48732ab671 100644
--- a/crates/buzz-cli/src/commands/listen.rs
+++ b/crates/buzz-cli/src/commands/listen.rs
@@ -36,7 +36,9 @@ fn parse_kinds(raw: Option<&str>) -> Result<Vec<u32>, CliError> {
                 out.push(k);
             }
             if out.is_empty() {
-                return Err(CliError::Usage("--kinds must list at least one kind".into()));
+                return Err(CliError::Usage(
+                    "--kinds must list at least one kind".into(),
+                ));
             }
             Ok(out)
         }
@@ -88,10 +90,7 @@ async fn post_webhook(client: &reqwest::Client, url: &str, body: &str) {
         .send()
         .await
     {
-        eprintln!(
-            "{}",
-            json!({"error":"webhook","message": format!("{e}")})
-        );
+        eprintln!("{}", json!({"error":"webhook","message": format!("{e}")}));
     }
 }
 
@@ -184,13 +183,10 @@ async fn listen_session(
 ) -> Result<(), CliError> {
     use buzz_ws_client::{NostrWsConnection, RelayMessage};
 
-    let mut conn = NostrWsConnection::connect_authenticated(
-        ws_url,
-        client.keys(),
-        client.auth_tag(),
-    )
-    .await
-    .map_err(|e| CliError::Other(format!("websocket: {e}")))?;
+    let mut conn =
+        NostrWsConnection::connect_authenticated(ws_url, client.keys(), client.auth_tag())
+            .await
+            .map_err(|e| CliError::Other(format!("websocket: {e}")))?;
 
     let sub_id = format!("buzz-listen-{}", &Uuid::new_v4().to_string()[..8]);
     let mut req = vec![json!("REQ"), json!(sub_id)];
@@ -215,8 +211,7 @@ async fn listen_session(
                     .map_err(|e| CliError::Other(format!("event serialize: {e}")))?;
                 let line = normalize_events(std::slice::from_ref(&raw));
                 // normalize_events returns a JSON array; unwrap first element for NDJSON stream.
-                let arr: Vec<serde_json::Value> =
-                    serde_json::from_str(&line).unwrap_or_default();
+                let arr: Vec<serde_json::Value> = serde_json::from_str(&line).unwrap_or_default();
                 let body = arr.first().cloned().unwrap_or(raw);
                 let text = serde_json::to_string(&body).unwrap_or_default();
                 println!("{text}");
@@ -256,7 +251,13 @@ mod tests {
     #[test]
     fn channel_filter_uses_h_tag() {
         let ch = "11111111-1111-1111-1111-111111111111".to_string();
-        let f = build_listen_filters(&[ch.clone()], false, &"a".repeat(64), DEFAULT_KINDS).unwrap();
+        let f = build_listen_filters(
+            std::slice::from_ref(&ch),
+            false,
+            &"a".repeat(64),
+            DEFAULT_KINDS,
+        )
+        .unwrap();
         assert_eq!(f.len(), 1);
         assert_eq!(f[0]["#h"][0], ch);
     }
diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs
index 0e5bca44d1..e8775b9e47 100644
--- a/crates/buzz-cli/src/commands/messages.rs
+++ b/crates/buzz-cli/src/commands/messages.rs
@@ -1378,7 +1378,6 @@ mod tests {
     }
 }
 
-
 #[cfg(test)]
 mod compact_format_tests {
     use super::format_events;
diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs
index 792891742c..901000e3d0 100644
--- a/crates/buzz-cli/src/commands/users.rs
+++ b/crates/buzz-cli/src/commands/users.rs
@@ -19,10 +19,7 @@ pub(crate) fn me_identity_json(pubkey_hex: &str, npub_bech32: &str) -> serde_jso
 /// Print the active CLI identity (hex pubkey + npub) from the loaded private key.
 ///
 /// No relay I/O — agents can resolve "who am I?" before connecting. Refs #2663.
-pub async fn cmd_me(
-    client: &BuzzClient,
-    _format: &crate::OutputFormat,
-) -> Result<(), CliError> {
+pub async fn cmd_me(client: &BuzzClient, _format: &crate::OutputFormat) -> Result<(), CliError> {
     let pk = client.keys().public_key();
     let hex = pk.to_hex();
     let npub = pk
diff --git a/crates/buzz-cli/src/managed_agent_publish.rs b/crates/buzz-cli/src/managed_agent_publish.rs
index 7a1d1fdca2..18ad9d73e1 100644
--- a/crates/buzz-cli/src/managed_agent_publish.rs
+++ b/crates/buzz-cli/src/managed_agent_publish.rs
@@ -151,8 +151,8 @@ pub fn build_managed_agent_event(
         .map_err(|e| CliError::Usage(format!("invalid agent pubkey: {e}")))?;
     let body = serde_json::to_string(content)
         .map_err(|e| CliError::Other(format!("serialize 30177: {e}")))?;
-    let d = Tag::parse(["d", agent_pubkey_hex])
-        .map_err(|e| CliError::Other(format!("d-tag: {e}")))?;
+    let d =
+        Tag::parse(["d", agent_pubkey_hex]).map_err(|e| CliError::Other(format!("d-tag: {e}")))?;
     Ok(EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), body).tags([d]))
 }
 
