From 07f2a332300520011b6eb8a0e6cb30faf5ff48d0 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 21:15:15 -0400
Subject: [PATCH 1/8] =?UTF-8?q?feat:=20add=20sprout-acp=20harness=20?=
 =?UTF-8?q?=E2=80=94=20bridges=20Sprout=20events=20to=20ACP=20agents?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Introduces sprout-acp, a new binary crate that connects AI agents to
Sprout via the Agent Client Protocol (ACP). The harness listens for
Sprout events over WebSocket and wakes agents with session/prompt
requests over stdio. Agents act on Sprout through the existing
sprout-mcp-server toolbox — the harness itself is a thin pipe that
never reasons or posts on behalf of the agent.

Architecture:

  Sprout Relay ──WS──→ Harness ──stdio──→ Agent (goose, codex, etc.)
                           │                  │
                           │  session/prompt   │  sprout-mcp-server
                           │  session/update ←─│  (send_message, etc.)
                           └── logs to stdout ─┘

Modules:
- acp.rs — ACP client over stdio JSON-RPC 2.0 (initialize, session/new,
  session/prompt, session/cancel, permission auto-approve)
- relay.rs — NIP-01 WebSocket + NIP-42 auth with background reader task
  for ping/pong during long agent turns, reconnect with since filter and
  event deduplication
- queue.rs — Per-channel event queues with global one-in-flight
  enforcement, FIFO fairness, batch drain, and requeue on failure
- config.rs — Env-var configuration with separate harness/agent keypairs
- main.rs — Orchestration loop with agent respawn, relay reconnect, turn
  timeout, and graceful shutdown

Also adds a small mention binary to sprout-test-client for sending
@mention events with proper #p tags during manual testing.

Tested end-to-end: @mention → harness → goose → MCP send_message → reply
appears in channel.

61 unit tests.
---
 Cargo.lock                                   |   22 +
 Cargo.toml                                   |    3 +-
 crates/sprout-acp/Cargo.toml                 |   51 +
 crates/sprout-acp/src/acp.rs                 |  877 +++++++++++
 crates/sprout-acp/src/config.rs              |  111 ++
 crates/sprout-acp/src/main.rs                |  325 ++++
 crates/sprout-acp/src/queue.rs               |  502 +++++++
 crates/sprout-acp/src/relay.rs               | 1408 ++++++++++++++++++
 crates/sprout-test-client/Cargo.toml         |    1 +
 crates/sprout-test-client/src/bin/mention.rs |   36 +
 10 files changed, 3335 insertions(+), 1 deletion(-)
 create mode 100644 crates/sprout-acp/Cargo.toml
 create mode 100644 crates/sprout-acp/src/acp.rs
 create mode 100644 crates/sprout-acp/src/config.rs
 create mode 100644 crates/sprout-acp/src/main.rs
 create mode 100644 crates/sprout-acp/src/queue.rs
 create mode 100644 crates/sprout-acp/src/relay.rs
 create mode 100644 crates/sprout-test-client/src/bin/mention.rs

diff --git a/Cargo.lock b/Cargo.lock
index 921f686d94..659a2740be 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2608,6 +2608,27 @@ dependencies = [
  "der",
 ]
 
+[[package]]
+name = "sprout-acp"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "futures-util",
+ "nostr",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sprout-core",
+ "thiserror",
+ "tokio",
+ "tokio-tungstenite 0.26.2",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "uuid",
+]
+
 [[package]]
 name = "sprout-admin"
 version = "0.1.0"
@@ -2824,6 +2845,7 @@ dependencies = [
 name = "sprout-test-client"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "futures-util",
  "nostr",
  "reqwest",
diff --git a/Cargo.toml b/Cargo.toml
index 1cc7379afa..70bdd281e9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ members = [
     "crates/sprout-search",
     "crates/sprout-audit",
     "crates/sprout-mcp",
+    "crates/sprout-acp",
     "crates/sprout-proxy",
     "crates/sprout-huddle",
     "crates/sprout-test-client",
@@ -26,7 +27,7 @@ repository = "https://github.com/sprout-rs/sprout"
 
 [workspace.dependencies]
 # Runtime
-tokio       = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal"] }
+tokio       = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal", "process"] }
 tokio-util  = { version = "0.7", features = ["rt"] }
 
 # HTTP + WebSocket
diff --git a/crates/sprout-acp/Cargo.toml b/crates/sprout-acp/Cargo.toml
new file mode 100644
index 0000000000..1cb33e21a5
--- /dev/null
+++ b/crates/sprout-acp/Cargo.toml
@@ -0,0 +1,51 @@
+[package]
+name = "sprout-acp"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "ACP harness that bridges Sprout events to AI agents"
+
+[[bin]]
+name = "sprout-acp"
+path = "src/main.rs"
+
+[dependencies]
+# Internal
+sprout-core = { workspace = true }
+
+# Nostr
+nostr = { workspace = true }
+
+# Async runtime
+tokio = { workspace = true }
+
+# WebSocket
+tokio-tungstenite = { workspace = true }
+futures-util = { workspace = true }
+
+# HTTP (channel discovery REST API)
+reqwest = { workspace = true }
+
+# Serialization
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+# IDs
+uuid = { workspace = true }
+chrono = { workspace = true }
+
+# URL parsing
+url = { workspace = true }
+
+# Logging
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+
+# Error handling
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util"] }
diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs
new file mode 100644
index 0000000000..20cf972ebd
--- /dev/null
+++ b/crates/sprout-acp/src/acp.rs
@@ -0,0 +1,877 @@
+//! ACP client module — manages communication with an AI agent subprocess over stdio
+//! using JSON-RPC 2.0 (newline-delimited / NDJSON).
+//!
+//! # Lifecycle
+//! 1. [`AcpClient::spawn`] — launch agent binary as subprocess
+//! 2. [`AcpClient::initialize`] — protocol version negotiation
+//! 3. [`AcpClient::session_new`] — create session with MCP server config
+//! 4. [`AcpClient::session_prompt`] — send prompt, receive streaming updates, return stop reason
+//! 5. [`AcpClient::session_cancel`] / [`AcpClient::cancel_with_cleanup`] — cancel in-flight turn
+
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::{Child, ChildStdin, ChildStdout};
+
+// ─── Public types ────────────────────────────────────────────────────────────
+
+/// An MCP server configuration passed to `session/new`.
+///
+/// Corresponds to the `McpServerStdio` variant in the ACP schema.
+/// All four fields are **required** by the schema (`args` and `env` may be empty arrays).
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct McpServer {
+    pub name: String,
+    pub command: String,
+    pub args: Vec<String>,
+    pub env: Vec<EnvVar>,
+}
+
+/// A single environment variable for an MCP server.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct EnvVar {
+    pub name: String,
+    pub value: String,
+}
+
+/// Stop reason returned by `session/prompt` when the agent finishes a turn.
+///
+/// Maps to the `stopReason` field in the `SessionPromptResponse`.
+#[derive(Debug, Clone, PartialEq)]
+pub enum StopReason {
+    /// Agent completed the turn normally (`"end_turn"`).
+    EndTurn,
+    /// Turn was cancelled via `session/cancel` (`"cancelled"`).
+    Cancelled,
+    /// Agent hit its token limit (`"max_tokens"`).
+    MaxTokens,
+    /// Agent hit its per-turn request limit (`"max_turn_requests"`).
+    MaxTurnRequests,
+    /// Agent refused the prompt (`"refusal"`).
+    /// Note: refused turns are dropped from history by the agent.
+    Refusal,
+}
+
+impl StopReason {
+    /// Parse a `stopReason` string from the ACP wire format.
+    pub fn from_str(s: &str) -> Option<Self> {
+        match s {
+            "end_turn" => Some(Self::EndTurn),
+            "cancelled" => Some(Self::Cancelled),
+            "max_tokens" => Some(Self::MaxTokens),
+            "max_turn_requests" => Some(Self::MaxTurnRequests),
+            "refusal" => Some(Self::Refusal),
+            _ => None,
+        }
+    }
+}
+
+/// Errors that can occur in the ACP client.
+#[derive(Debug, thiserror::Error)]
+pub enum AcpError {
+    #[error("IO error: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("JSON error: {0}")]
+    Json(#[from] serde_json::Error),
+
+    #[error("Agent process exited unexpectedly")]
+    AgentExited,
+
+    #[error("Protocol error: {0}")]
+    Protocol(String),
+}
+
+// ─── AcpClient ───────────────────────────────────────────────────────────────
+
+/// ACP client that owns an agent subprocess and communicates over its stdio.
+///
+/// One `AcpClient` per agent process. Multiple sessions can be created on the
+/// same client via repeated calls to [`session_new`](AcpClient::session_new).
+pub struct AcpClient {
+    /// The agent child process (kept alive to prevent zombie).
+    child: Child,
+    /// Write end of the agent's stdin pipe.
+    stdin: ChildStdin,
+    /// Buffered reader over the agent's stdout pipe (line-oriented).
+    reader: BufReader<ChildStdout>,
+    /// Monotonically increasing JSON-RPC request id counter.
+    /// Harness-generated IDs are always numeric.
+    next_id: u64,
+    /// The id of a `session/request_permission` request that has been received
+    /// but not yet responded to. Stored as `serde_json::Value` because JSON-RPC 2.0
+    /// permits both numeric and string IDs from the agent.
+    /// Used by [`cancel_with_cleanup`](AcpClient::cancel_with_cleanup) to send
+    /// a `cancelled` outcome before the agent returns from `session/prompt`.
+    pending_permission_id: Option<serde_json::Value>,
+    /// Whether we have already sent a response to the pending permission request.
+    /// Guards against double-response if a timeout fires after the allow_once
+    /// response was written but before `pending_permission_id` was cleared.
+    permission_responded: bool,
+}
+
+impl AcpClient {
+    // ── Lifecycle ─────────────────────────────────────────────────────────
+
+    /// Spawn the agent binary as a subprocess and connect to its stdio pipes.
+    ///
+    /// After spawning, call [`initialize`](Self::initialize) before any other method.
+    pub async fn spawn(command: &str, args: &[String]) -> Result<Self, AcpError> {
+        use std::process::Stdio;
+
+        let mut cmd = tokio::process::Command::new(command);
+        cmd.args(args)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            // Inherit stderr so agent logs are visible in the harness terminal.
+            .stderr(Stdio::inherit());
+
+        let mut child = cmd.spawn()?;
+
+        let stdin = child
+            .stdin
+            .take()
+            .ok_or_else(|| AcpError::Protocol("failed to open agent stdin".into()))?;
+        let stdout = child
+            .stdout
+            .take()
+            .ok_or_else(|| AcpError::Protocol("failed to open agent stdout".into()))?;
+
+        Ok(Self {
+            child,
+            stdin,
+            reader: BufReader::new(stdout),
+            next_id: 0,
+            pending_permission_id: None,
+            permission_responded: false,
+        })
+    }
+
+    /// Send the `initialize` request and return the agent's response result value.
+    ///
+    /// Must be called exactly once, before any other ACP method.
+    /// The caller may inspect `agentCapabilities` in the returned value.
+    pub async fn initialize(&mut self) -> Result<serde_json::Value, AcpError> {
+        let params = serde_json::json!({
+            "protocolVersion": 1,
+            "clientCapabilities": {},
+            "clientInfo": {
+                "name": "sprout-acp",
+                "version": env!("CARGO_PKG_VERSION")
+            }
+        });
+        let result = self.send_request("initialize", params).await?;
+        tracing::debug!(target: "acp::init", "initialize response: {result}");
+        Ok(result)
+    }
+
+    /// Send `session/new` and return the `sessionId` string.
+    ///
+    /// `cwd` must be an absolute path. `mcp_servers` may be empty.
+    pub async fn session_new(
+        &mut self,
+        cwd: &str,
+        mcp_servers: Vec<McpServer>,
+    ) -> Result<String, AcpError> {
+        let params = serde_json::json!({
+            "cwd": cwd,
+            "mcpServers": mcp_servers,
+        });
+        let result = self.send_request("session/new", params).await?;
+        let session_id = result["sessionId"]
+            .as_str()
+            .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))?
+            .to_owned();
+        tracing::info!(target: "acp::session", "session created: {session_id}");
+        Ok(session_id)
+    }
+
+    /// Send `session/prompt` and block until the agent returns a stop reason.
+    ///
+    /// While waiting, incoming `session/update` notifications are logged and
+    /// `session/request_permission` requests are auto-approved with `allow_once`.
+    pub async fn session_prompt(
+        &mut self,
+        session_id: &str,
+        prompt_text: &str,
+    ) -> Result<StopReason, AcpError> {
+        let params = serde_json::json!({
+            "sessionId": session_id,
+            "prompt": [
+                { "type": "text", "text": prompt_text }
+            ]
+        });
+        let result = self.send_request("session/prompt", params).await?;
+        self.parse_stop_reason(&result)
+    }
+
+    /// Send a `session/cancel` **notification** (no `id` field, no response expected).
+    ///
+    /// After calling this, the agent will eventually respond to the in-flight
+    /// `session/prompt` with `stopReason: "cancelled"`. Use
+    /// [`cancel_with_cleanup`](Self::cancel_with_cleanup) if you need to drain
+    /// that response.
+    ///
+    /// Note: async because writing to stdin requires async I/O.
+    pub async fn session_cancel(&mut self, session_id: &str) -> Result<(), AcpError> {
+        let params = serde_json::json!({
+            "sessionId": session_id,
+        });
+        self.send_notification("session/cancel", params).await
+    }
+
+    /// Cancel a turn cleanly, handling any pending permission request first.
+    ///
+    /// Steps:
+    /// 1. If there is a pending `session/request_permission` that hasn't been
+    ///    responded to yet, respond with `outcome: "cancelled"`.
+    /// 2. Send `session/cancel` notification (no id).
+    /// 3. Continue reading until the `session/prompt` response arrives with `stopReason: "cancelled"`.
+    ///
+    /// Returns the final [`StopReason`] (almost always [`StopReason::Cancelled`]).
+    pub async fn cancel_with_cleanup(
+        &mut self,
+        session_id: &str,
+    ) -> Result<StopReason, AcpError> {
+        // Step 1: respond to any pending permission request with "cancelled",
+        // but only if we haven't already responded (guards against double-response race).
+        if let Some(perm_id) = self.pending_permission_id.clone() {
+            if !self.permission_responded {
+                let response = permission_response_cancelled(&perm_id);
+                self.write_ndjson(&response).await?;
+                tracing::debug!(
+                    target: "acp::cancel",
+                    "responded cancelled to pending permission id={perm_id}"
+                );
+            }
+            self.pending_permission_id = None;
+            self.permission_responded = false;
+        }
+
+        // Step 2: send session/cancel notification (no id)
+        self.session_cancel(session_id).await?;
+        tracing::info!(target: "acp::cancel", "sent session/cancel for {session_id}");
+
+        // Step 3: drain until we get the session/prompt response.
+        // The prompt id is next_id - 1 (the most recently sent request was session/prompt).
+        let prompt_id = self.next_id - 1;
+        let result = self.read_until_response(prompt_id).await?;
+        self.parse_stop_reason(&result)
+    }
+
+    // ── Internal helpers ──────────────────────────────────────────────────
+
+    /// Serialize `value` as a single NDJSON line and flush to the agent's stdin.
+    async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> {
+        let line = serde_json::to_string(value)?;
+        self.stdin.write_all(line.as_bytes()).await?;
+        self.stdin.write_all(b"\n").await?;
+        self.stdin.flush().await?;
+        Ok(())
+    }
+
+    /// Send a JSON-RPC request and wait for the matching response.
+    ///
+    /// Assigns the next available id, writes the NDJSON line to stdin,
+    /// then calls [`read_until_response`](Self::read_until_response).
+    async fn send_request(
+        &mut self,
+        method: &str,
+        params: serde_json::Value,
+    ) -> Result<serde_json::Value, AcpError> {
+        let id = self.next_id;
+        self.next_id += 1;
+
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": id,
+            "method": method,
+            "params": params,
+        });
+
+        tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default());
+        self.write_ndjson(&msg).await?;
+
+        self.read_until_response(id).await
+    }
+
+    /// Send a JSON-RPC **notification** — no `id` field, no response expected.
+    ///
+    /// Used for `session/cancel`. The absence of `id` is the JSON-RPC 2.0
+    /// distinguisher between requests and notifications.
+    async fn send_notification(
+        &mut self,
+        method: &str,
+        params: serde_json::Value,
+    ) -> Result<(), AcpError> {
+        // Notifications deliberately have NO "id" field.
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "method": method,
+            "params": params,
+        });
+
+        tracing::debug!(target: "acp::wire", "→ (notification) {}", &serde_json::to_string(&msg).unwrap_or_default());
+        self.write_ndjson(&msg).await?;
+        Ok(())
+    }
+
+    /// Core message loop: read NDJSON lines until we get a response matching `expected_id`.
+    ///
+    /// While waiting, handles:
+    /// - `session/update` notifications → logged via tracing
+    /// - `session/request_permission` requests → auto-approved with `allow_once`
+    /// - Any other messages → debug-logged and ignored
+    ///
+    /// Compares the incoming `id` field as a `serde_json::Value` against
+    /// `json!(expected_id)` so that both numeric and string IDs work correctly.
+    async fn read_until_response(
+        &mut self,
+        expected_id: u64,
+    ) -> Result<serde_json::Value, AcpError> {
+        loop {
+            let mut line = String::new();
+            let n = self.reader.read_line(&mut line).await?;
+            if n == 0 {
+                return Err(AcpError::AgentExited);
+            }
+
+            let trimmed = line.trim();
+            if trimmed.is_empty() {
+                continue;
+            }
+
+            tracing::debug!(target: "acp::wire", "← {trimmed}");
+
+            let msg: serde_json::Value = match serde_json::from_str(trimmed) {
+                Ok(v) => v,
+                Err(e) => {
+                    tracing::warn!(
+                        target: "acp::wire",
+                        "failed to parse line as JSON: {e} — skipping"
+                    );
+                    continue;
+                }
+            };
+
+            // Check if this is a response to our expected request (has matching id).
+            // Compare as serde_json::Value so both numeric and string IDs work.
+            if let Some(id) = msg.get("id") {
+                if *id == serde_json::json!(expected_id) {
+                    if let Some(error) = msg.get("error") {
+                        return Err(AcpError::Protocol(error.to_string()));
+                    }
+                    return Ok(msg["result"].clone());
+                }
+                // Has an id but not ours — fall through to method dispatch
+                // (e.g., session/request_permission has both id and method).
+            }
+
+            // Dispatch by method name (notifications and agent-initiated requests).
+            if let Some(method) = msg.get("method").and_then(|v| v.as_str()) {
+                match method {
+                    "session/update" => {
+                        self.handle_session_update(&msg);
+                    }
+                    "session/request_permission" => {
+                        self.handle_permission_request(&msg).await?;
+                    }
+                    other => {
+                        tracing::debug!(target: "acp::wire", "ignoring unknown method: {other}");
+                    }
+                }
+            }
+        }
+    }
+
+    /// Log a `session/update` notification via tracing.
+    ///
+    /// The discriminator field is `sessionUpdate` (not `type`) per the ACP schema.
+    fn handle_session_update(&self, msg: &serde_json::Value) {
+        let update = &msg["params"]["update"];
+        let update_type = update
+            .get("sessionUpdate")
+            .and_then(|v| v.as_str())
+            .unwrap_or("unknown");
+
+        match update_type {
+            "agent_message_chunk" => {
+                if let Some(text) = update["content"]["text"].as_str() {
+                    tracing::info!(target: "acp::stream", "{text}");
+                }
+            }
+            "tool_call" => {
+                let title = update
+                    .get("title")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or("unknown");
+                let kind = update
+                    .get("kind")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or("unknown");
+                tracing::info!(target: "acp::tool", "tool_call: {title} ({kind})");
+            }
+            "tool_call_update" => {
+                let tool_id = update
+                    .get("toolCallId")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or("?");
+                let status = update
+                    .get("status")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or("?");
+                tracing::info!(target: "acp::tool", "tool_call_update: {tool_id} → {status}");
+            }
+            "plan" => {
+                tracing::info!(target: "acp::plan", "plan update received");
+            }
+            "agent_thought_chunk" => {
+                if let Some(text) = update["content"]["text"].as_str() {
+                    tracing::debug!(target: "acp::thought", "{text}");
+                }
+            }
+            other => {
+                tracing::debug!(target: "acp::update", "session/update: {other}");
+            }
+        }
+    }
+
+    /// Auto-approve a `session/request_permission` request from the agent.
+    ///
+    /// Finds the option with `kind == "allow_once"` and responds with its `optionId`.
+    /// If no `allow_once` option exists, falls back to `reject_once`.
+    ///
+    /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`.
+    ///
+    /// The request `id` is stored as `serde_json::Value` to support both numeric
+    /// and string IDs per JSON-RPC 2.0.
+    async fn handle_permission_request(
+        &mut self,
+        msg: &serde_json::Value,
+    ) -> Result<(), AcpError> {
+        // Extract id as a Value — JSON-RPC 2.0 allows both numeric and string IDs.
+        let id = msg
+            .get("id")
+            .cloned()
+            .ok_or_else(|| AcpError::Protocol("permission request missing id".into()))?;
+
+        // Store pending permission id so cancel_with_cleanup can respond to it.
+        self.pending_permission_id = Some(id.clone());
+        // Mark as not yet responded — guards against double-response race.
+        self.permission_responded = false;
+
+        let options = msg["params"]["options"]
+            .as_array()
+            .ok_or_else(|| AcpError::Protocol("permission request missing options".into()))?;
+
+        tracing::debug!(
+            target: "acp::permission",
+            "session/request_permission id={id}, {} options",
+            options.len()
+        );
+
+        // Find allow_once by kind — NEVER hardcode optionId.
+        let allow_once = options
+            .iter()
+            .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
+
+        let response = if let Some(opt) = allow_once {
+            let option_id = opt["optionId"]
+                .as_str()
+                .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?;
+            tracing::info!(
+                target: "acp::permission",
+                "auto-approving permission id={id} with allow_once optionId={option_id:?}"
+            );
+            permission_response_selected(&id, option_id)
+        } else {
+            // No allow_once — fall back to reject_once.
+            tracing::warn!(
+                target: "acp::permission",
+                "no allow_once option found in permission request id={id}, falling back to reject_once"
+            );
+            let reject = options
+                .iter()
+                .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
+
+            if let Some(opt) = reject {
+                let option_id = opt["optionId"].as_str().unwrap_or("reject");
+                permission_response_selected(&id, option_id)
+            } else {
+                return Err(AcpError::Protocol(
+                    "no suitable permission option found (neither allow_once nor reject_once)"
+                        .into(),
+                ));
+            }
+        };
+
+        self.write_ndjson(&response).await?;
+        // Mark as responded and clear pending now that we've successfully written the response.
+        self.permission_responded = true;
+        self.pending_permission_id = None;
+        Ok(())
+    }
+
+    /// Parse `stopReason` from a `session/prompt` result value.
+    fn parse_stop_reason(&self, result: &serde_json::Value) -> Result<StopReason, AcpError> {
+        let raw = result["stopReason"].as_str().ok_or_else(|| {
+            AcpError::Protocol("session/prompt response missing stopReason".into())
+        })?;
+        StopReason::from_str(raw)
+            .ok_or_else(|| AcpError::Protocol(format!("unknown stopReason: {raw:?}")))
+    }
+}
+
+// ─── Permission response constructors ────────────────────────────────────────
+
+/// Build a JSON-RPC permission response with `outcome: "selected"`.
+fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value {
+    serde_json::json!({
+        "jsonrpc": "2.0",
+        "id": id,
+        "result": { "outcome": { "outcome": "selected", "optionId": option_id } }
+    })
+}
+
+/// Build a JSON-RPC permission response with `outcome: "cancelled"`.
+fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
+    serde_json::json!({
+        "jsonrpc": "2.0",
+        "id": id,
+        "result": { "outcome": { "outcome": "cancelled" } }
+    })
+}
+
+// ─── Drop: kill child process ─────────────────────────────────────────────────
+
+impl Drop for AcpClient {
+    fn drop(&mut self) {
+        // Best-effort kill — ignore errors (process may have already exited).
+        let _ = self.child.start_kill();
+    }
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── StopReason parsing ────────────────────────────────────────────────
+
+    #[test]
+    fn stop_reason_parses_all_known_values() {
+        assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn));
+        assert_eq!(
+            StopReason::from_str("cancelled"),
+            Some(StopReason::Cancelled)
+        );
+        assert_eq!(
+            StopReason::from_str("max_tokens"),
+            Some(StopReason::MaxTokens)
+        );
+        assert_eq!(
+            StopReason::from_str("max_turn_requests"),
+            Some(StopReason::MaxTurnRequests)
+        );
+        assert_eq!(StopReason::from_str("refusal"), Some(StopReason::Refusal));
+    }
+
+    #[test]
+    fn stop_reason_returns_none_for_unknown() {
+        assert_eq!(StopReason::from_str("unknown_value"), None);
+        assert_eq!(StopReason::from_str(""), None);
+        assert_eq!(StopReason::from_str("END_TURN"), None); // case-sensitive
+        assert_eq!(StopReason::from_str("endturn"), None); // no camelCase
+    }
+
+    // ── Permission option finding ─────────────────────────────────────────
+
+    #[test]
+    fn find_allow_once_by_kind_not_by_option_id() {
+        // optionId values are intentionally non-obvious to prove we don't hardcode them.
+        let options: Vec<serde_json::Value> = serde_json::from_str(
+            r#"[
+            {"optionId": "opt-reject-42",  "name": "Reject",       "kind": "reject_once"},
+            {"optionId": "opt-allow-99",   "name": "Allow once",   "kind": "allow_once"},
+            {"optionId": "opt-always-7",   "name": "Always allow", "kind": "allow_always"}
+        ]"#,
+        )
+        .unwrap();
+
+        let allow_once = options
+            .iter()
+            .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
+
+        assert!(allow_once.is_some(), "should find allow_once option");
+        let opt = allow_once.unwrap();
+        // Found by kind, not by hardcoded optionId
+        assert_eq!(opt["kind"].as_str(), Some("allow_once"));
+        assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99"));
+    }
+
+    #[test]
+    fn find_allow_once_returns_none_when_absent() {
+        let options: Vec<serde_json::Value> = serde_json::from_str(
+            r#"[
+            {"optionId": "reject-1",      "name": "Reject",        "kind": "reject_once"},
+            {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"}
+        ]"#,
+        )
+        .unwrap();
+
+        let allow_once = options
+            .iter()
+            .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
+
+        assert!(allow_once.is_none());
+    }
+
+    #[test]
+    fn find_reject_once_fallback_when_no_allow_once() {
+        let options: Vec<serde_json::Value> = serde_json::from_str(
+            r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#,
+        )
+        .unwrap();
+
+        let allow_once = options
+            .iter()
+            .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
+        assert!(allow_once.is_none());
+
+        let reject_once = options
+            .iter()
+            .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
+        assert!(reject_once.is_some());
+        assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x"));
+    }
+
+    // ── JSON-RPC message construction ─────────────────────────────────────
+
+    #[test]
+    fn request_has_id_field() {
+        let id: u64 = 42;
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": id,
+            "method": "initialize",
+            "params": {}
+        });
+        assert!(msg.get("id").is_some(), "request must have id field");
+        assert_eq!(msg["id"].as_u64(), Some(42));
+        assert_eq!(msg["jsonrpc"].as_str(), Some("2.0"));
+        assert_eq!(msg["method"].as_str(), Some("initialize"));
+    }
+
+    #[test]
+    fn notification_has_no_id_field() {
+        // session/cancel is a notification — must NOT have an id field.
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "method": "session/cancel",
+            "params": {
+                "sessionId": "sess_abc123"
+            }
+        });
+        assert!(
+            msg.get("id").is_none(),
+            "notification must NOT have id field"
+        );
+        assert_eq!(msg["jsonrpc"].as_str(), Some("2.0"));
+        assert_eq!(msg["method"].as_str(), Some("session/cancel"));
+    }
+
+    #[test]
+    fn initialize_request_format() {
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": 0u64,
+            "method": "initialize",
+            "params": {
+                "protocolVersion": 1,
+                "clientCapabilities": {},
+                "clientInfo": {
+                    "name": "sprout-acp",
+                    "version": "0.1.0"
+                }
+            }
+        });
+        assert_eq!(msg["params"]["protocolVersion"].as_u64(), Some(1));
+        assert_eq!(
+            msg["params"]["clientInfo"]["name"].as_str(),
+            Some("sprout-acp")
+        );
+        assert!(msg["params"]["clientCapabilities"].is_object());
+    }
+
+    #[test]
+    fn session_new_mcp_server_has_required_fields() {
+        // Schema requires name, command, args, env — all present, args/env may be empty.
+        let server = McpServer {
+            name: "sprout-mcp".into(),
+            command: "/usr/local/bin/sprout-mcp-server".into(),
+            args: vec![],
+            env: vec![
+                EnvVar {
+                    name: "SPROUT_RELAY_URL".into(),
+                    value: "ws://localhost:3000".into(),
+                },
+                EnvVar {
+                    name: "SPROUT_PRIVATE_KEY".into(),
+                    value: "nsec1abc".into(),
+                },
+            ],
+        };
+        let serialized = serde_json::to_value(&server).unwrap();
+        assert_eq!(serialized["name"].as_str(), Some("sprout-mcp"));
+        assert_eq!(
+            serialized["command"].as_str(),
+            Some("/usr/local/bin/sprout-mcp-server")
+        );
+        assert!(serialized["args"].is_array());
+        assert_eq!(serialized["args"].as_array().unwrap().len(), 0);
+        assert!(serialized["env"].is_array());
+        assert_eq!(serialized["env"].as_array().unwrap().len(), 2);
+        assert_eq!(
+            serialized["env"][0]["name"].as_str(),
+            Some("SPROUT_RELAY_URL")
+        );
+    }
+
+    #[test]
+    fn session_prompt_request_format() {
+        let prompt_text = "[Sprout @mention]\nChannel: test\nFrom: npub1...\nMessage: hello";
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": 2u64,
+            "method": "session/prompt",
+            "params": {
+                "sessionId": "sess_abc123",
+                "prompt": [
+                    { "type": "text", "text": prompt_text }
+                ]
+            }
+        });
+        assert_eq!(msg["method"].as_str(), Some("session/prompt"));
+        let prompt = msg["params"]["prompt"].as_array().unwrap();
+        assert_eq!(prompt.len(), 1);
+        assert_eq!(prompt[0]["type"].as_str(), Some("text"));
+        assert_eq!(prompt[0]["text"].as_str(), Some(prompt_text));
+    }
+
+    #[test]
+    fn permission_response_selected_format() {
+        let id: u64 = 5;
+        let option_id = "opt-allow-99";
+        let response = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": id,
+            "result": {
+                "outcome": {
+                    "outcome": "selected",
+                    "optionId": option_id
+                }
+            }
+        });
+        assert_eq!(response["id"].as_u64(), Some(5));
+        assert_eq!(
+            response["result"]["outcome"]["outcome"].as_str(),
+            Some("selected")
+        );
+        assert_eq!(
+            response["result"]["outcome"]["optionId"].as_str(),
+            Some("opt-allow-99")
+        );
+    }
+
+    #[test]
+    fn permission_response_cancelled_format() {
+        let id: u64 = 5;
+        let response = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": id,
+            "result": {
+                "outcome": {
+                    "outcome": "cancelled"
+                }
+            }
+        });
+        assert_eq!(
+            response["result"]["outcome"]["outcome"].as_str(),
+            Some("cancelled")
+        );
+        // cancelled outcome has no optionId
+        assert!(response["result"]["outcome"].get("optionId").is_none());
+    }
+
+    #[test]
+    fn session_cancel_notification_has_session_id_in_params() {
+        let session_id = "sess_xyz789";
+        let msg = serde_json::json!({
+            "jsonrpc": "2.0",
+            "method": "session/cancel",
+            "params": {
+                "sessionId": session_id
+            }
+        });
+        // Must have no id (notification)
+        assert!(msg.get("id").is_none());
+        // Must have sessionId in params
+        assert_eq!(
+            msg["params"]["sessionId"].as_str(),
+            Some("sess_xyz789")
+        );
+    }
+
+    // ── String ID handling (Fix 1) ────────────────────────────────────────
+
+    #[test]
+    fn permission_request_with_string_id() {
+        // Verify that permission response uses the same ID type as the request.
+        // JSON-RPC 2.0 permits string IDs from the agent.
+        let string_id = serde_json::json!("perm-req-001");
+        let response = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": string_id,
+            "result": {
+                "outcome": { "outcome": "selected", "optionId": "allow-once" }
+            }
+        });
+        assert_eq!(response["id"], "perm-req-001");
+        assert!(response["id"].is_string());
+    }
+
+    #[test]
+    fn id_comparison_works_for_numeric_and_string() {
+        // Verify json!(expected_id) comparison logic used in read_until_response.
+        let expected_id: u64 = 3;
+        let numeric_response_id = serde_json::json!(3u64);
+        let string_response_id = serde_json::json!("3");
+
+        // Numeric matches
+        assert_eq!(numeric_response_id, serde_json::json!(expected_id));
+        // String does NOT match numeric (correct — different types)
+        assert_ne!(string_response_id, serde_json::json!(expected_id));
+    }
+
+    #[test]
+    fn permission_cancelled_response_preserves_id_type() {
+        // String ID from agent should be echoed back as string in cancelled response.
+        let string_id = serde_json::json!("req-abc");
+        let cancelled = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": string_id.clone(),
+            "result": { "outcome": { "outcome": "cancelled" } }
+        });
+        assert_eq!(cancelled["id"], string_id);
+        assert!(cancelled["id"].is_string());
+
+        // Numeric ID from agent should be echoed back as numeric.
+        let numeric_id = serde_json::json!(42u64);
+        let cancelled_numeric = serde_json::json!({
+            "jsonrpc": "2.0",
+            "id": numeric_id.clone(),
+            "result": { "outcome": { "outcome": "cancelled" } }
+        });
+        assert_eq!(cancelled_numeric["id"], numeric_id);
+        assert!(cancelled_numeric["id"].is_number());
+    }
+}
diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs
new file mode 100644
index 0000000000..c738486684
--- /dev/null
+++ b/crates/sprout-acp/src/config.rs
@@ -0,0 +1,111 @@
+use nostr::Keys;
+use thiserror::Error;
+
+/// Errors that can occur when loading configuration from environment variables.
+#[derive(Debug, Error)]
+pub enum ConfigError {
+    #[error("required environment variable {0} is not set")]
+    MissingVar(&'static str),
+
+    #[error("failed to parse nostr keys from {var}: {source}")]
+    KeyParse {
+        var: &'static str,
+        #[source]
+        source: nostr::key::Error,
+    },
+}
+
+/// Configuration for the sprout-acp harness.
+#[derive(Debug)]
+pub struct Config {
+    // --- Harness identity (relay WebSocket connection) ---
+    /// Harness's nostr keypair, parsed from `SPROUT_ACP_PRIVATE_KEY`.
+    pub harness_keys: Keys,
+    /// Harness's API token (`SPROUT_ACP_API_TOKEN`), optional.
+    pub harness_api_token: Option<String>,
+    /// Relay WebSocket URL (`SPROUT_RELAY_URL`). Default: `ws://localhost:3000`.
+    pub relay_url: String,
+
+    // --- Agent identity (passed to MCP server via env vars) ---
+    /// Agent's nostr keypair, parsed from `SPROUT_AGENT_PRIVATE_KEY`.
+    pub agent_keys: Keys,
+    /// Agent's API token (`SPROUT_AGENT_API_TOKEN`), optional.
+    pub agent_api_token: Option<String>,
+
+    // --- Agent binary ---
+    /// Agent command (`SPROUT_ACP_AGENT_COMMAND`). Default: `goose`.
+    pub agent_command: String,
+    /// Agent arguments (`SPROUT_ACP_AGENT_ARGS`, comma-separated). Default: `["acp"]`.
+    pub agent_args: Vec<String>,
+
+    // --- MCP server ---
+    /// MCP server binary path (`SPROUT_ACP_MCP_COMMAND`). Default: `sprout-mcp-server`.
+    pub mcp_command: String,
+
+    // --- Tuning ---
+    /// Maximum turn duration in seconds (`SPROUT_ACP_TURN_TIMEOUT`). Default: 300.
+    pub turn_timeout_secs: u64,
+}
+
+/// Parse a nostr `Keys` from the named environment variable.
+fn parse_keys_var(var: &'static str) -> Result<Keys, ConfigError> {
+    let nsec = std::env::var(var).map_err(|_| ConfigError::MissingVar(var))?;
+    Keys::parse(&nsec).map_err(|e| ConfigError::KeyParse { var, source: e })
+}
+
+impl Config {
+    /// Load configuration from environment variables.
+    pub fn from_env() -> Result<Self, ConfigError> {
+        let harness_keys = parse_keys_var("SPROUT_ACP_PRIVATE_KEY")?;
+
+        let harness_api_token = std::env::var("SPROUT_ACP_API_TOKEN").ok();
+
+        let relay_url = std::env::var("SPROUT_RELAY_URL")
+            .unwrap_or_else(|_| "ws://localhost:3000".to_string());
+
+        let agent_keys = parse_keys_var("SPROUT_AGENT_PRIVATE_KEY")?;
+
+        let agent_api_token = std::env::var("SPROUT_AGENT_API_TOKEN").ok();
+
+        let agent_command = std::env::var("SPROUT_ACP_AGENT_COMMAND")
+            .unwrap_or_else(|_| "goose".to_string());
+
+        let agent_args = std::env::var("SPROUT_ACP_AGENT_ARGS")
+            .map(|s| s.split(',').map(|a| a.trim().to_string()).collect())
+            .unwrap_or_else(|_| vec!["acp".to_string()]);
+
+        let mcp_command = std::env::var("SPROUT_ACP_MCP_COMMAND")
+            .unwrap_or_else(|_| "sprout-mcp-server".to_string());
+
+        let turn_timeout_secs = std::env::var("SPROUT_ACP_TURN_TIMEOUT")
+            .ok()
+            .and_then(|s| s.parse::<u64>().ok())
+            .unwrap_or(300);
+
+        Ok(Config {
+            harness_keys,
+            harness_api_token,
+            relay_url,
+            agent_keys,
+            agent_api_token,
+            agent_command,
+            agent_args,
+            mcp_command,
+            turn_timeout_secs,
+        })
+    }
+
+    /// Return a human-readable summary (no secrets).
+    pub fn summary(&self) -> String {
+        format!(
+            "relay={} harness_pubkey={} agent_pubkey={} agent_cmd={} {} mcp_cmd={} turn_timeout={}s",
+            self.relay_url,
+            self.harness_keys.public_key().to_hex(),
+            self.agent_keys.public_key().to_hex(),
+            self.agent_command,
+            self.agent_args.join(" "),
+            self.mcp_command,
+            self.turn_timeout_secs,
+        )
+    }
+}
diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs
new file mode 100644
index 0000000000..9062a235a5
--- /dev/null
+++ b/crates/sprout-acp/src/main.rs
@@ -0,0 +1,325 @@
+mod acp;
+mod config;
+mod queue;
+mod relay;
+
+use std::collections::HashMap;
+use std::time::Duration;
+
+use anyhow::Result;
+use nostr::ToBech32;
+use tokio::time::timeout;
+use tracing_subscriber::EnvFilter;
+use uuid::Uuid;
+
+use acp::{AcpClient, AcpError, EnvVar, McpServer, StopReason};
+use config::Config;
+use queue::{EventQueue, QueuedEvent};
+use relay::HarnessRelay;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| EnvFilter::new("sprout_acp=info")),
+        )
+        .compact()
+        .init();
+
+    let config = Config::from_env().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?;
+    tracing::info!("sprout-acp starting: {}", config.summary());
+
+    // ── Step 1: Spawn ACP agent subprocess and initialize ─────────────────────
+    let mut acp = spawn_and_init(&config).await?;
+
+    // ── Step 2: Connect to Sprout relay ──────────────────────────────────────
+    let agent_pubkey_hex = config.agent_keys.public_key().to_hex();
+    let mut relay = HarnessRelay::connect(
+        &config.relay_url,
+        &config.harness_keys,
+        config.harness_api_token.as_deref(),
+        &agent_pubkey_hex,
+    )
+    .await
+    .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?;
+
+    tracing::info!("connected to relay at {}", config.relay_url);
+
+    // ── Step 3: Discover channels and subscribe ───────────────────────────────
+    let channels = relay
+        .discover_channels()
+        .await
+        .map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?;
+
+    tracing::info!("discovered {} channel(s)", channels.len());
+
+    for channel_id in &channels {
+        if let Err(e) = relay.subscribe_channel(*channel_id).await {
+            tracing::warn!("failed to subscribe to channel {channel_id}: {e}");
+        } else {
+            tracing::info!("subscribed to channel {channel_id}");
+        }
+    }
+
+    // ── Step 4: Main orchestration loop ──────────────────────────────────────
+    let mut sessions: HashMap<Uuid, String> = HashMap::new();
+    let mut queue = EventQueue::new();
+    let mcp_servers = build_mcp_servers(&config);
+    let turn_timeout = Duration::from_secs(config.turn_timeout_secs);
+
+    loop {
+        // Wait for the next relay event or shutdown signal.
+        let sprout_event = tokio::select! {
+            event = relay.next_event() => event,
+            _ = tokio::signal::ctrl_c() => {
+                tracing::info!("shutting down (SIGINT/SIGTERM)");
+                break;
+            }
+        };
+
+        match sprout_event {
+            Some(sprout_event) => {
+                // Push event into the queue.
+                queue.push(QueuedEvent {
+                    channel_id: sprout_event.channel_id,
+                    event: sprout_event.event,
+                    received_at: std::time::Instant::now(),
+                });
+
+                // Try to flush and process batches.
+                loop {
+                    let batch = match queue.flush_next() {
+                        Some(b) => b,
+                        None => break,
+                    };
+
+                    let channel_id = batch.channel_id;
+                    // Format prompt before potentially requeuing (borrows batch by ref).
+                    let prompt_text = queue::format_prompt(&batch);
+
+                    // Get or create session for this channel.
+                    let session_id = match get_or_create_session(
+                        &mut sessions,
+                        channel_id,
+                        &mut acp,
+                        &mcp_servers,
+                    )
+                    .await
+                    {
+                        Ok(id) => id,
+                        Err(e) => {
+                            tracing::error!(
+                                "failed to create session for channel {channel_id}: {e}"
+                            );
+                            // Re-queue so events aren't lost on transient session failure.
+                            // Break (not continue) to avoid hot-loop on persistent failure.
+                            queue.requeue(batch);
+                            queue.mark_complete();
+                            break;
+                        }
+                    };
+
+                    tracing::info!(
+                        "prompting agent for channel {channel_id} (session {session_id}, {} event(s))",
+                        batch.events.len()
+                    );
+
+                    // Send prompt with turn timeout.
+                    let prompt_result = timeout(
+                        turn_timeout,
+                        acp.session_prompt(&session_id, &prompt_text),
+                    )
+                    .await;
+
+                    match prompt_result {
+                        Ok(Ok(stop_reason)) => {
+                            log_stop_reason(channel_id, &stop_reason);
+                            queue.mark_complete();
+                        }
+                        Ok(Err(AcpError::AgentExited)) => {
+                            tracing::error!("agent process exited — respawning");
+                            sessions.clear();
+                            // Re-queue so events are retried after respawn.
+                            queue.requeue(batch);
+                            queue.mark_complete();
+
+                            acp = respawn_agent(&config).await?;
+                            // Break inner drain loop — sessions are gone, will recreate on next event
+                            break;
+                        }
+                        Ok(Err(e)) => {
+                            tracing::error!(
+                                "session_prompt error for channel {channel_id}: {e}"
+                            );
+                            // Re-queue so events aren't lost on transient prompt error.
+                            // Break (not continue) to avoid hot-loop on persistent failure.
+                            queue.requeue(batch);
+                            queue.mark_complete();
+                            sessions.remove(&channel_id);
+                            break;
+                        }
+                        Err(_elapsed) => {
+                            // Turn timeout — cancel the in-flight prompt.
+                            // On timeout the events have been "processed" (agent was working on them)
+                            // so we do NOT requeue — the turn was attempted and timed out.
+                            tracing::warn!(
+                                "turn timeout ({}s) for channel {channel_id} — cancelling",
+                                config.turn_timeout_secs
+                            );
+                            match acp.cancel_with_cleanup(&session_id).await {
+                                Ok(stop_reason) => {
+                                    log_stop_reason(channel_id, &stop_reason);
+                                }
+                                Err(AcpError::AgentExited) => {
+                                    tracing::error!(
+                                        "agent exited during cancel — respawning"
+                                    );
+                                    sessions.clear();
+                                    acp = respawn_agent(&config).await?;
+                                }
+                                Err(e) => {
+                                    tracing::error!("cancel_with_cleanup error: {e}");
+                                }
+                            }
+                            queue.mark_complete();
+                            break;
+                        }
+                    }
+                }
+            }
+            None => {
+                // Relay connection lost — reconnect.
+                tracing::warn!("relay connection lost — reconnecting");
+                loop {
+                    match relay.reconnect().await {
+                        Ok(()) => {
+                            tracing::info!("relay reconnected");
+                            break;
+                        }
+                        Err(e) => {
+                            tracing::error!("relay reconnect failed: {e} — retrying in 5s");
+                            tokio::time::sleep(Duration::from_secs(5)).await;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    tracing::info!("sprout-acp stopped");
+    Ok(())
+}
+
+// ── Helper: respawn agent after exit ─────────────────────────────────────────
+
+/// Spawn a fresh agent process, initialize it, and return the new [`AcpClient`].
+///
+/// Logs success/failure at the appropriate level. Returns `Err` only if the
+/// spawn or initialize step fails fatally (caller should propagate to `main`).
+async fn respawn_agent(config: &Config) -> Result<AcpClient> {
+    match spawn_and_init(config).await {
+        Ok(new_acp) => {
+            tracing::info!("agent respawned successfully");
+            Ok(new_acp)
+        }
+        Err(e) => {
+            tracing::error!("failed to respawn agent: {e}");
+            Err(e)
+        }
+    }
+}
+
+// ── Helper: spawn agent and initialize ───────────────────────────────────────
+
+async fn spawn_and_init(config: &Config) -> Result<AcpClient> {
+    let mut acp = AcpClient::spawn(&config.agent_command, &config.agent_args)
+        .await
+        .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?;
+
+    let init_result = acp
+        .initialize()
+        .await
+        .map_err(|e| anyhow::anyhow!("agent initialize failed: {e}"))?;
+
+    tracing::info!("agent initialized: {init_result}");
+    Ok(acp)
+}
+
+// ── Helper: get or create session for a channel ───────────────────────────────
+
+async fn get_or_create_session(
+    sessions: &mut HashMap<Uuid, String>,
+    channel_id: Uuid,
+    acp: &mut AcpClient,
+    mcp_servers: &[McpServer],
+) -> Result<String, AcpError> {
+    if let Some(session_id) = sessions.get(&channel_id) {
+        return Ok(session_id.clone());
+    }
+
+    let cwd = std::env::current_dir()
+        .unwrap_or_else(|_| std::path::PathBuf::from("/"))
+        .to_string_lossy()
+        .to_string();
+
+    let session_id = acp.session_new(&cwd, mcp_servers.to_vec()).await?;
+    tracing::info!("created session {session_id} for channel {channel_id}");
+    sessions.insert(channel_id, session_id.clone());
+    Ok(session_id)
+}
+
+// ── Helper: build MCP server config from Config ───────────────────────────────
+
+fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
+    vec![McpServer {
+        name: "sprout-mcp".to_string(),
+        command: config.mcp_command.clone(),
+        args: vec![],
+        env: {
+            let mut env = vec![
+                EnvVar {
+                    name: "SPROUT_RELAY_URL".into(),
+                    value: config.relay_url.clone(),
+                },
+                EnvVar {
+                    name: "SPROUT_PRIVATE_KEY".into(),
+                    value: config
+                        .agent_keys
+                        .secret_key()
+                        .to_bech32()
+                        .unwrap_or_default(),
+                },
+            ];
+            if let Some(ref token) = config.agent_api_token {
+                env.push(EnvVar {
+                    name: "SPROUT_API_TOKEN".into(),
+                    value: token.clone(),
+                });
+            }
+            env
+        },
+    }]
+}
+
+// ── Helper: log stop reason at appropriate level ──────────────────────────────
+
+fn log_stop_reason(channel_id: Uuid, stop_reason: &StopReason) {
+    match stop_reason {
+        StopReason::EndTurn => {
+            tracing::info!("turn complete for channel {channel_id}: end_turn");
+        }
+        StopReason::Cancelled => {
+            tracing::warn!("turn cancelled for channel {channel_id}");
+        }
+        StopReason::MaxTokens => {
+            tracing::warn!("turn hit max_tokens for channel {channel_id}");
+        }
+        StopReason::MaxTurnRequests => {
+            tracing::warn!("turn hit max_turn_requests for channel {channel_id}");
+        }
+        StopReason::Refusal => {
+            tracing::warn!("turn refused for channel {channel_id}");
+        }
+    }
+}
diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs
new file mode 100644
index 0000000000..3bf337a71f
--- /dev/null
+++ b/crates/sprout-acp/src/queue.rs
@@ -0,0 +1,502 @@
+//! Event queue state machine for sprout-acp.
+//!
+//! Manages per-channel event queues with a global one-in-flight constraint.
+//! When the harness is ready to prompt the agent, it flushes the channel with
+//! the oldest pending event, draining ALL events for that channel into a single
+//! batch. Only one `session/prompt` is in flight at a time across all channels.
+
+use nostr::{Event, ToBech32};
+use std::collections::{HashMap, VecDeque};
+use std::time::Instant;
+use uuid::Uuid;
+
+/// An event waiting in the queue.
+#[derive(Debug, Clone)]
+pub struct QueuedEvent {
+    pub channel_id: Uuid,
+    pub event: Event,
+    pub received_at: Instant,
+}
+
+/// A batch of events to prompt the agent with.
+#[derive(Debug)]
+pub struct FlushBatch {
+    pub channel_id: Uuid,
+    pub events: Vec<Event>,
+}
+
+/// Per-channel event queue with global one-in-flight enforcement.
+///
+/// # State Machine
+///
+/// ```text
+/// State:
+///   queues: Map<channel_id, VecDeque<QueuedEvent>>
+///   prompt_in_flight: bool
+///
+/// Transitions:
+///   push(event):
+///     queues[event.channel_id].push_back(event)
+///
+///   flush_next() → Option<FlushBatch>:
+///     if prompt_in_flight: return None
+///     if all queues empty: return None
+///     channel = pick channel with oldest head event (min received_at)
+///     events = drain queues[channel]
+///     remove queues[channel] if now empty
+///     prompt_in_flight = true
+///     return Some(FlushBatch { channel, events })
+///
+///   mark_complete():
+///     prompt_in_flight = false
+/// ```
+pub struct EventQueue {
+    queues: HashMap<Uuid, VecDeque<QueuedEvent>>,
+    prompt_in_flight: bool,
+}
+
+impl EventQueue {
+    /// Create a new empty event queue.
+    pub fn new() -> Self {
+        Self {
+            queues: HashMap::new(),
+            prompt_in_flight: false,
+        }
+    }
+
+    /// Push an event into the queue for its channel.
+    pub fn push(&mut self, event: QueuedEvent) {
+        self.queues
+            .entry(event.channel_id)
+            .or_default()
+            .push_back(event);
+    }
+
+    /// Try to flush the next batch.
+    ///
+    /// Returns `None` if a prompt is already in flight or if all queues are
+    /// empty. Otherwise picks the channel with the oldest pending event (FIFO
+    /// fairness across channels), drains ALL events for that channel into a
+    /// single batch, sets `prompt_in_flight = true`, and returns the batch.
+    pub fn flush_next(&mut self) -> Option<FlushBatch> {
+        if self.prompt_in_flight {
+            return None;
+        }
+
+        // Find the channel whose head event has the oldest received_at.
+        let channel_id = self
+            .queues
+            .iter()
+            .filter(|(_, q)| !q.is_empty())
+            .min_by_key(|(_, q)| q.front().unwrap().received_at)
+            .map(|(id, _)| *id)?;
+
+        // Drain ALL events for that channel.
+        let queue = self.queues.remove(&channel_id)?;
+        let events: Vec<Event> = queue.into_iter().map(|qe| qe.event).collect();
+
+        self.prompt_in_flight = true;
+
+        Some(FlushBatch { channel_id, events })
+    }
+
+    /// Mark the current prompt as complete. Clears `prompt_in_flight`.
+    pub fn mark_complete(&mut self) {
+        self.prompt_in_flight = false;
+    }
+
+    /// Re-queue a batch of events that failed to process.
+    ///
+    /// Events are pushed back to the **front** of the channel's queue so they
+    /// are processed first on the next flush cycle. This prevents event loss
+    /// when session creation or `session/prompt` fails transiently.
+    ///
+    /// Note: `received_at` is reset to `Instant::now()` for re-queued events.
+    /// This means a re-queued channel competes fairly with other channels rather
+    /// than always winning due to stale timestamps.
+    pub fn requeue(&mut self, batch: FlushBatch) {
+        let queue = self
+            .queues
+            .entry(batch.channel_id)
+            .or_insert_with(VecDeque::new);
+        // Push to front in reverse order so original order is preserved.
+        for event in batch.events.into_iter().rev() {
+            queue.push_front(QueuedEvent {
+                channel_id: batch.channel_id,
+                event,
+                received_at: Instant::now(),
+            });
+        }
+    }
+
+    /// Whether a prompt is currently in flight.
+    pub fn is_in_flight(&self) -> bool {
+        self.prompt_in_flight
+    }
+
+    /// Total number of pending events across all channels.
+    pub fn pending_count(&self) -> usize {
+        self.queues.values().map(|q| q.len()).sum()
+    }
+
+    /// Number of channels with pending events.
+    pub fn pending_channels(&self) -> usize {
+        self.queues.len()
+    }
+}
+
+impl Default for EventQueue {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+/// Format the Channel/From/Time/Message lines for a single event.
+fn format_event_lines(channel_id: Uuid, event: &Event) -> String {
+    format!(
+        "Channel: {}\nFrom: {}\nTime: {}\nMessage: {}",
+        channel_id,
+        event
+            .pubkey
+            .to_bech32()
+            .unwrap_or_else(|_| event.pubkey.to_hex()),
+        chrono::DateTime::from_timestamp(event.created_at.as_u64() as i64, 0)
+            .map(|dt| dt.to_rfc3339())
+            .unwrap_or_else(|| event.created_at.as_u64().to_string()),
+        event.content,
+    )
+}
+
+/// Format a batch of events into a prompt string for the agent.
+pub fn format_prompt(batch: &FlushBatch) -> String {
+    if batch.events.len() == 1 {
+        format!(
+            "[Sprout @mention]\n{}",
+            format_event_lines(batch.channel_id, &batch.events[0])
+        )
+    } else {
+        let mut prompt = format!("[Sprout @mention — {} events]\n", batch.events.len());
+        for (i, event) in batch.events.iter().enumerate() {
+            prompt.push_str(&format!(
+                "\n--- Event {} ---\n{}\n",
+                i + 1,
+                format_event_lines(batch.channel_id, event)
+            ));
+        }
+        prompt
+    }
+}
+
+// ─── Unit Tests ──────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nostr::{EventBuilder, Keys, Kind};
+    use std::time::Duration;
+
+    /// Build a test event with the given content.
+    fn make_event(content: &str) -> Event {
+        let keys = Keys::generate();
+        EventBuilder::new(Kind::Custom(40001), content, [])
+            .sign_with_keys(&keys)
+            .unwrap()
+    }
+
+    /// Build a QueuedEvent for the given channel.
+    fn make_queued(channel_id: Uuid, content: &str) -> QueuedEvent {
+        QueuedEvent {
+            channel_id,
+            event: make_event(content),
+            received_at: Instant::now(),
+        }
+    }
+
+    /// Build a QueuedEvent with a specific `received_at` offset from now.
+    fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent {
+        QueuedEvent {
+            channel_id,
+            event: make_event(content),
+            received_at: Instant::now() - age,
+        }
+    }
+
+    // ── Test 1: push + flush_next basic ──────────────────────────────────────
+
+    #[test]
+    fn test_push_flush_basic() {
+        let mut q = EventQueue::new();
+        let ch = Uuid::new_v4();
+
+        q.push(make_queued(ch, "hello"));
+
+        let batch = q.flush_next().expect("should return a batch");
+        assert_eq!(batch.channel_id, ch);
+        assert_eq!(batch.events.len(), 1);
+        assert_eq!(batch.events[0].content, "hello");
+
+        // Queue should be empty now.
+        assert_eq!(q.pending_count(), 0);
+        assert_eq!(q.pending_channels(), 0);
+    }
+
+    // ── Test 2: in_flight blocks flush ───────────────────────────────────────
+
+    #[test]
+    fn test_in_flight_blocks_flush() {
+        let mut q = EventQueue::new();
+        let ch = Uuid::new_v4();
+
+        q.push(make_queued(ch, "first"));
+        let _batch = q.flush_next().expect("first flush should succeed");
+        assert!(q.is_in_flight());
+
+        // Push another event while in-flight.
+        q.push(make_queued(ch, "second"));
+
+        // flush_next must return None while in-flight.
+        assert!(q.flush_next().is_none());
+    }
+
+    // ── Test 3: mark_complete enables flush ──────────────────────────────────
+
+    #[test]
+    fn test_mark_complete_enables_flush() {
+        let mut q = EventQueue::new();
+        let ch = Uuid::new_v4();
+
+        q.push(make_queued(ch, "first"));
+        let _batch = q.flush_next().expect("first flush should succeed");
+
+        // Push while in-flight; flush blocked.
+        q.push(make_queued(ch, "second"));
+        assert!(q.flush_next().is_none());
+
+        // Complete the in-flight prompt.
+        q.mark_complete();
+        assert!(!q.is_in_flight());
+
+        // Now flush should succeed.
+        let batch = q.flush_next().expect("should flush after mark_complete");
+        assert_eq!(batch.channel_id, ch);
+        assert_eq!(batch.events.len(), 1);
+        assert_eq!(batch.events[0].content, "second");
+    }
+
+    // ── Test 4: batch drain ───────────────────────────────────────────────────
+
+    #[test]
+    fn test_batch_drain_all_events() {
+        let mut q = EventQueue::new();
+        let ch = Uuid::new_v4();
+
+        q.push(make_queued(ch, "msg1"));
+        q.push(make_queued(ch, "msg2"));
+        q.push(make_queued(ch, "msg3"));
+
+        assert_eq!(q.pending_count(), 3);
+
+        let batch = q.flush_next().expect("should return batch");
+        assert_eq!(batch.channel_id, ch);
+        assert_eq!(batch.events.len(), 3);
+        assert_eq!(batch.events[0].content, "msg1");
+        assert_eq!(batch.events[1].content, "msg2");
+        assert_eq!(batch.events[2].content, "msg3");
+
+        // All drained.
+        assert_eq!(q.pending_count(), 0);
+        assert_eq!(q.pending_channels(), 0);
+    }
+
+    // ── Test 5: FIFO fairness ─────────────────────────────────────────────────
+
+    #[test]
+    fn test_fifo_fairness_picks_oldest_channel() {
+        let mut q = EventQueue::new();
+        let ch_a = Uuid::new_v4();
+        let ch_b = Uuid::new_v4();
+
+        // Channel A has an older event (2 seconds ago), B has a newer one (1 second ago).
+        q.push(make_queued_at(ch_a, "from A", Duration::from_secs(2)));
+        q.push(make_queued_at(ch_b, "from B", Duration::from_secs(1)));
+
+        let batch = q.flush_next().expect("should return batch");
+        // A is older, so it should be picked first.
+        assert_eq!(batch.channel_id, ch_a);
+        assert_eq!(batch.events[0].content, "from A");
+    }
+
+    // ── Test 6: multi-channel interleave ─────────────────────────────────────
+
+    #[test]
+    fn test_multi_channel_interleave() {
+        let mut q = EventQueue::new();
+        let ch_a = Uuid::new_v4();
+        let ch_b = Uuid::new_v4();
+
+        // A is older.
+        q.push(make_queued_at(ch_a, "A-event", Duration::from_secs(2)));
+        q.push(make_queued_at(ch_b, "B-event", Duration::from_secs(1)));
+
+        // First flush picks A.
+        let batch_a = q.flush_next().expect("first flush");
+        assert_eq!(batch_a.channel_id, ch_a);
+        assert!(q.is_in_flight());
+
+        // B still pending.
+        assert_eq!(q.pending_count(), 1);
+        assert_eq!(q.pending_channels(), 1);
+
+        q.mark_complete();
+
+        // Second flush picks B.
+        let batch_b = q.flush_next().expect("second flush");
+        assert_eq!(batch_b.channel_id, ch_b);
+        assert_eq!(batch_b.events[0].content, "B-event");
+
+        assert_eq!(q.pending_count(), 0);
+    }
+
+    // ── Test 7: empty queue returns None ─────────────────────────────────────
+
+    #[test]
+    fn test_empty_queue_returns_none() {
+        let mut q = EventQueue::new();
+        assert!(q.flush_next().is_none());
+    }
+
+    // ── Test 8: pending_count ─────────────────────────────────────────────────
+
+    #[test]
+    fn test_pending_count() {
+        let mut q = EventQueue::new();
+        let ch_a = Uuid::new_v4();
+        let ch_b = Uuid::new_v4();
+
+        assert_eq!(q.pending_count(), 0);
+        assert_eq!(q.pending_channels(), 0);
+
+        q.push(make_queued(ch_a, "a1"));
+        q.push(make_queued(ch_a, "a2"));
+        q.push(make_queued(ch_b, "b1"));
+
+        assert_eq!(q.pending_count(), 3);
+        assert_eq!(q.pending_channels(), 2);
+
+        // Flush A (2 events drained).
+        let _ = q.flush_next();
+        assert_eq!(q.pending_count(), 1);
+        assert_eq!(q.pending_channels(), 1);
+
+        q.mark_complete();
+
+        // Flush B (1 event drained).
+        let _ = q.flush_next();
+        assert_eq!(q.pending_count(), 0);
+        assert_eq!(q.pending_channels(), 0);
+    }
+
+    // ── Test 9: format_prompt single event ───────────────────────────────────
+
+    #[test]
+    fn test_format_prompt_single() {
+        let ch = Uuid::new_v4();
+        let event = make_event("Hello @agent");
+        let npub = event
+            .pubkey
+            .to_bech32()
+            .unwrap_or_else(|_| event.pubkey.to_hex());
+
+        let batch = FlushBatch {
+            channel_id: ch,
+            events: vec![event],
+        };
+
+        let prompt = format_prompt(&batch);
+
+        assert!(prompt.starts_with("[Sprout @mention]\n"));
+        assert!(prompt.contains(&format!("Channel: {}", ch)));
+        assert!(prompt.contains(&format!("From: {}", npub)));
+        assert!(prompt.contains("Message: Hello @agent"));
+        // Should NOT contain "--- Event 1 ---" (that's the multi-event format).
+        assert!(!prompt.contains("--- Event 1 ---"));
+    }
+
+    // ── Test 9b: requeue preserves events ────────────────────────────────────
+
+    #[test]
+    fn test_requeue_preserves_events() {
+        let mut queue = EventQueue::new();
+        let ch = Uuid::new_v4();
+        queue.push(make_queued(ch, "msg1"));
+        queue.push(make_queued(ch, "msg2"));
+
+        let batch = queue.flush_next().unwrap();
+        assert_eq!(batch.events.len(), 2);
+        assert!(queue.is_in_flight());
+
+        // Simulate failure — requeue the batch.
+        queue.requeue(batch);
+        queue.mark_complete();
+
+        // Should be able to flush again and get the same events in order.
+        let batch2 = queue.flush_next().unwrap();
+        assert_eq!(batch2.events.len(), 2);
+        assert_eq!(batch2.events[0].content, "msg1");
+        assert_eq!(batch2.events[1].content, "msg2");
+    }
+
+    #[test]
+    fn test_requeue_interleaves_with_other_channels() {
+        let mut queue = EventQueue::new();
+        let ch_a = Uuid::new_v4();
+        let ch_b = Uuid::new_v4();
+
+        // ch_a has an older event.
+        queue.push(make_queued_at(ch_a, "A-old", Duration::from_secs(5)));
+        queue.push(make_queued_at(ch_b, "B-new", Duration::from_secs(1)));
+
+        // Flush ch_a first (older).
+        let batch_a = queue.flush_next().unwrap();
+        assert_eq!(batch_a.channel_id, ch_a);
+
+        // Requeue ch_a (simulating failure) and complete.
+        queue.requeue(batch_a);
+        queue.mark_complete();
+
+        // After requeue, ch_a's received_at is reset to now, so ch_b (older) goes first.
+        let next_batch = queue.flush_next().unwrap();
+        assert_eq!(next_batch.channel_id, ch_b);
+    }
+
+    // ── Test 10: format_prompt batch ─────────────────────────────────────────
+
+    #[test]
+    fn test_format_prompt_batch() {
+        let ch = Uuid::new_v4();
+        let e1 = make_event("first message");
+        let e2 = make_event("second message");
+        let e3 = make_event("third message");
+
+        let batch = FlushBatch {
+            channel_id: ch,
+            events: vec![e1, e2, e3],
+        };
+
+        let prompt = format_prompt(&batch);
+
+        assert!(prompt.starts_with("[Sprout @mention — 3 events]\n"));
+        assert!(prompt.contains("--- Event 1 ---"));
+        assert!(prompt.contains("--- Event 2 ---"));
+        assert!(prompt.contains("--- Event 3 ---"));
+        assert!(prompt.contains("Message: first message"));
+        assert!(prompt.contains("Message: second message"));
+        assert!(prompt.contains("Message: third message"));
+        // All events reference the same channel.
+        assert_eq!(
+            prompt.matches(&format!("Channel: {}", ch)).count(),
+            3,
+            "each event block should include the channel id"
+        );
+    }
+}
diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs
new file mode 100644
index 0000000000..66a485718e
--- /dev/null
+++ b/crates/sprout-acp/src/relay.rs
@@ -0,0 +1,1408 @@
+//! Harness-side Sprout relay client.
+//!
+//! Connects to the Sprout relay via NIP-01 WebSocket, authenticates via NIP-42,
+//! discovers channels via REST API, and streams matching events back to the
+//! harness main loop.
+//!
+//! This is a simplified receive-only client adapted from `sprout-mcp`'s
+//! `relay_client.rs`. It does not publish events or perform queries — it only
+//! subscribes and receives.
+//!
+//! ## Architecture
+//!
+//! A background tokio task owns the WebSocket stream. It:
+//! - Responds to Ping frames with Pong (preventing relay disconnect on long turns)
+//! - Forwards `SproutEvent`s through an `mpsc` channel
+//! - Handles reconnection with `since` filters to avoid event loss
+//! - Responds to mid-session AUTH challenges
+//!
+//! `HarnessRelay` communicates with the background task via a `RelayCommand`
+//! channel. `next_event()` reads from the event receiver.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::time::Duration;
+
+// ─── Named constants ──────────────────────────────────────────────────────────
+
+/// Capacity of the event channel from background task to harness.
+const EVENT_CHANNEL_CAPACITY: usize = 256;
+/// Capacity of the command channel from harness to background task.
+const CMD_CHANNEL_CAPACITY: usize = 64;
+/// Maximum number of seen event IDs before the dedup set is cleared.
+const SEEN_ID_LIMIT: usize = 12_000;
+/// Seconds subtracted from `since` on resubscribe to tolerate clock skew.
+const SINCE_SKEW_SECS: u64 = 5;
+/// Timeout for the NIP-42 auth handshake steps.
+const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
+
+use futures_util::{SinkExt, StreamExt};
+use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl};
+use serde_json::{json, Value};
+use tokio::sync::mpsc;
+use tokio::time::timeout;
+use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
+use tracing::{debug, info, warn};
+use uuid::Uuid;
+
+use sprout_core::kind::{
+    KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
+};
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+/// Events the harness cares about.
+#[derive(Debug, Clone)]
+pub struct SproutEvent {
+    /// Which channel this event belongs to.
+    pub channel_id: Uuid,
+    /// The underlying Nostr event.
+    pub event: Event,
+}
+
+/// Errors from relay operations.
+#[derive(Debug, thiserror::Error)]
+pub enum RelayError {
+    #[error("WebSocket error: {0}")]
+    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
+
+    #[error("JSON error: {0}")]
+    Json(#[from] serde_json::Error),
+
+    #[error("Auth failed: {0}")]
+    AuthFailed(String),
+
+    #[error("No auth challenge received")]
+    NoAuthChallenge,
+
+    #[error("Connection closed")]
+    ConnectionClosed,
+
+    #[error("Timeout")]
+    Timeout,
+
+    #[error("HTTP error: {0}")]
+    Http(String),
+
+    #[error("Unexpected message: {0}")]
+    UnexpectedMessage(String),
+}
+
+impl From<nostr::event::builder::Error> for RelayError {
+    fn from(e: nostr::event::builder::Error) -> Self {
+        RelayError::AuthFailed(e.to_string())
+    }
+}
+
+// ── Internal relay message types ──────────────────────────────────────────────
+
+/// A parsed NIP-01 relay message.
+#[derive(Debug, Clone)]
+enum RelayMessage {
+    Event {
+        subscription_id: String,
+        event: Box<Event>,
+    },
+    Ok {
+        event_id: String,
+        accepted: bool,
+        message: String,
+    },
+    Eose {
+        subscription_id: String,
+    },
+    Closed {
+        subscription_id: String,
+        message: String,
+    },
+    Notice {
+        message: String,
+    },
+    Auth {
+        challenge: String,
+    },
+}
+
+// ── Commands sent from HarnessRelay to the background task ───────────────────
+
+/// Commands sent from `HarnessRelay` to the background WebSocket task.
+enum RelayCommand {
+    /// Subscribe to a channel (sends a NIP-01 REQ).
+    Subscribe { channel_id: Uuid },
+    /// Unsubscribe from a channel (sends a NIP-01 CLOSE).
+    Unsubscribe { channel_id: Uuid },
+    /// Reconnect to the relay (re-authenticate and resubscribe).
+    Reconnect,
+    /// Shut down the background task.
+    Shutdown,
+}
+
+// ── WebSocket stream type alias ───────────────────────────────────────────────
+
+type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
+
+// ── HarnessRelay ──────────────────────────────────────────────────────────────
+
+/// Harness-side relay client.
+///
+/// Connects to the Sprout relay, authenticates via NIP-42, and streams
+/// matching events for subscribed channels.
+///
+/// A background tokio task owns the WebSocket connection and responds to
+/// Ping frames, preventing disconnection during long agent turns.
+pub struct HarnessRelay {
+    /// Receiver for events forwarded by the background task.
+    event_rx: mpsc::Receiver<Option<SproutEvent>>,
+    /// Sender for commands to the background task.
+    cmd_tx: mpsc::Sender<RelayCommand>,
+    /// HTTP client for REST API calls.
+    http: reqwest::Client,
+    /// WebSocket URL of the relay.
+    relay_url: String,
+    /// Optional API token for Bearer auth.
+    api_token: Option<String>,
+    /// Keys used for NIP-42 signing.
+    keys: Keys,
+    /// Agent public key (hex) used as the `#p` filter on subscriptions.
+    agent_pubkey_hex: String,
+    /// Handle to the background task (for clean shutdown).
+    bg_handle: tokio::task::JoinHandle<()>,
+}
+
+impl HarnessRelay {
+    // ── Public API ────────────────────────────────────────────────────────────
+
+    /// Connect to relay and authenticate via NIP-42.
+    pub async fn connect(
+        relay_url: &str,
+        keys: &Keys,
+        api_token: Option<&str>,
+        agent_pubkey_hex: &str,
+    ) -> Result<Self, RelayError> {
+        // Perform the initial connection and auth handshake.
+        let (ws, _buffer) = do_connect(relay_url, keys, api_token).await?;
+
+        let (event_tx, event_rx) = mpsc::channel::<Option<SproutEvent>>(EVENT_CHANNEL_CAPACITY);
+        let (cmd_tx, cmd_rx) = mpsc::channel::<RelayCommand>(CMD_CHANNEL_CAPACITY);
+
+        let bg_keys = keys.clone();
+        let bg_relay_url = relay_url.to_string();
+        let bg_api_token = api_token.map(|t| t.to_string());
+        let bg_agent_pubkey_hex = agent_pubkey_hex.to_string();
+
+        let bg_handle = tokio::spawn(async move {
+            run_background_task(
+                ws,
+                event_tx,
+                cmd_rx,
+                bg_keys,
+                bg_relay_url,
+                bg_api_token,
+                bg_agent_pubkey_hex,
+            )
+            .await;
+        });
+
+        Ok(Self {
+            event_rx,
+            cmd_tx,
+            http: reqwest::Client::new(),
+            relay_url: relay_url.to_string(),
+            api_token: api_token.map(|t| t.to_string()),
+            keys: keys.clone(),
+            agent_pubkey_hex: agent_pubkey_hex.to_string(),
+            bg_handle,
+        })
+    }
+
+    /// Discover channels the harness is a member of via `GET /api/channels`.
+    pub async fn discover_channels(&self) -> Result<Vec<Uuid>, RelayError> {
+        let http_url = relay_ws_to_http(&self.relay_url);
+        let url = format!("{http_url}/api/channels");
+
+        let builder = self.http.get(&url);
+        let builder = apply_auth(builder, &self.api_token, &self.keys);
+
+        let resp = builder
+            .send()
+            .await
+            .map_err(|e| RelayError::Http(e.to_string()))?;
+
+        if !resp.status().is_success() {
+            return Err(RelayError::Http(format!(
+                "GET /api/channels returned HTTP {}",
+                resp.status()
+            )));
+        }
+
+        let body: Value = resp
+            .json()
+            .await
+            .map_err(|e| RelayError::Http(e.to_string()))?;
+
+        let channels = body
+            .as_array()
+            .ok_or_else(|| RelayError::Http("expected JSON array from /api/channels".into()))?;
+
+        let mut ids = Vec::with_capacity(channels.len());
+        for ch in channels {
+            if let Some(id_str) = ch.get("id").and_then(|v| v.as_str()) {
+                match id_str.parse::<Uuid>() {
+                    Ok(uuid) => ids.push(uuid),
+                    Err(e) => {
+                        warn!("skipping channel with unparseable id {id_str:?}: {e}");
+                    }
+                }
+            }
+        }
+
+        debug!("discovered {} channel(s)", ids.len());
+        Ok(ids)
+    }
+
+    /// Subscribe to events in a channel filtered by agent pubkey.
+    ///
+    /// Sends a `Subscribe` command to the background task, which issues the
+    /// NIP-01 `REQ` for kinds 40001, 46010, 40007 tagged with the channel UUID
+    /// and agent pubkey. Subscription ID is `ch-<uuid>`.
+    pub async fn subscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> {
+        self.cmd_tx
+            .send(RelayCommand::Subscribe { channel_id })
+            .await
+            .map_err(|_| RelayError::ConnectionClosed)?;
+        debug!("queued subscribe for channel {channel_id}");
+        Ok(())
+    }
+
+    /// Unsubscribe from a channel.
+    pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> {
+        self.cmd_tx
+            .send(RelayCommand::Unsubscribe { channel_id })
+            .await
+            .map_err(|_| RelayError::ConnectionClosed)?;
+        debug!("queued unsubscribe for channel {channel_id}");
+        Ok(())
+    }
+
+    /// Wait for the next event from any subscribed channel.
+    ///
+    /// Reads from the background task's event channel. Returns `None` on
+    /// connection loss — the caller should call [`reconnect`](Self::reconnect).
+    pub async fn next_event(&mut self) -> Option<SproutEvent> {
+        // The background task sends `None` to signal connection loss.
+        self.event_rx.recv().await.flatten()
+    }
+
+    /// Reconnect after connection loss. Instructs the background task to
+    /// re-authenticate and resubscribe to all previously active channels.
+    pub async fn reconnect(&mut self) -> Result<(), RelayError> {
+        warn!("relay connection lost — reconnecting…");
+        self.cmd_tx
+            .send(RelayCommand::Reconnect)
+            .await
+            .map_err(|_| RelayError::ConnectionClosed)?;
+        Ok(())
+    }
+}
+
+impl Drop for HarnessRelay {
+    fn drop(&mut self) {
+        // Best-effort shutdown signal; ignore errors (task may already be done).
+        let _ = self.cmd_tx.try_send(RelayCommand::Shutdown);
+        self.bg_handle.abort();
+    }
+}
+
+// ── Background task ───────────────────────────────────────────────────────────
+
+/// State maintained by the background WebSocket task.
+struct BgState {
+    /// Active subscriptions: channel_id → subscription_id string.
+    active_subscriptions: HashMap<Uuid, String>,
+    /// Most recent `created_at` timestamp seen per channel (for `since` filter).
+    last_seen: HashMap<Uuid, u64>,
+    /// Set of event IDs seen, for deduplication.
+    seen_ids: HashSet<String>,
+}
+
+impl BgState {
+    fn new() -> Self {
+        Self {
+            active_subscriptions: HashMap::new(),
+            last_seen: HashMap::new(),
+            seen_ids: HashSet::new(),
+        }
+    }
+
+    /// Record a received event for dedup and `since` tracking.
+    /// Returns `true` if the event is new (not a duplicate).
+    fn record_event(&mut self, channel_id: Uuid, event: &Event) -> bool {
+        let id_hex = event.id.to_hex();
+
+        // Deduplicate.
+        if !self.seen_ids.insert(id_hex) {
+            return false;
+        }
+
+        // Bound seen_ids to prevent unbounded memory growth.
+        if self.seen_ids.len() > SEEN_ID_LIMIT {
+            // HashSet has no ordering, so we clear and re-insert the current
+            // event to avoid a false-negative dedup gap for this event.
+            let current_id = event.id.to_hex();
+            self.seen_ids.clear();
+            self.seen_ids.insert(current_id);
+        }
+
+        // Update last_seen timestamp.
+        let ts = event.created_at.as_u64();
+        self.last_seen
+            .entry(channel_id)
+            .and_modify(|t| *t = (*t).max(ts))
+            .or_insert(ts);
+
+        true
+    }
+}
+
+/// The main background task loop.
+///
+/// Owns the WebSocket stream, responds to Pings, forwards events, and handles
+/// reconnection.
+async fn run_background_task(
+    mut ws: WsStream,
+    event_tx: mpsc::Sender<Option<SproutEvent>>,
+    mut cmd_rx: mpsc::Receiver<RelayCommand>,
+    keys: Keys,
+    relay_url: String,
+    api_token: Option<String>,
+    agent_pubkey_hex: String,
+) {
+    let mut state = BgState::new();
+
+    loop {
+        tokio::select! {
+            // ── Incoming WebSocket message ────────────────────────────────────
+            raw = ws.next() => {
+                match raw {
+                    Some(Ok(msg)) => {
+                        if !handle_ws_message(
+                            msg,
+                            &mut ws,
+                            &event_tx,
+                            &mut state,
+                            &keys,
+                            &relay_url,
+                            api_token.as_deref(),
+                        )
+                        .await
+                        {
+                            // handle_ws_message returns false on connection loss.
+                            // Signal the caller and wait for a Reconnect command.
+                            let _ = event_tx.send(None).await;
+                            wait_for_reconnect(
+                                &mut ws,
+                                &mut cmd_rx,
+                                &mut state,
+                                &keys,
+                                &relay_url,
+                                api_token.as_deref(),
+                                &agent_pubkey_hex,
+                            )
+                            .await;
+                        }
+                    }
+                    Some(Err(e)) => {
+                        warn!("WebSocket error in background task: {e}");
+                        let _ = event_tx.send(None).await;
+                        wait_for_reconnect(
+                            &mut ws,
+                            &mut cmd_rx,
+                            &mut state,
+                            &keys,
+                            &relay_url,
+                            api_token.as_deref(),
+                            &agent_pubkey_hex,
+                        )
+                        .await;
+                    }
+                    None => {
+                        debug!("WebSocket stream ended");
+                        let _ = event_tx.send(None).await;
+                        wait_for_reconnect(
+                            &mut ws,
+                            &mut cmd_rx,
+                            &mut state,
+                            &keys,
+                            &relay_url,
+                            api_token.as_deref(),
+                            &agent_pubkey_hex,
+                        )
+                        .await;
+                    }
+                }
+            }
+
+            // ── Command from HarnessRelay ─────────────────────────────────────
+            cmd = cmd_rx.recv() => {
+                match cmd {
+                    Some(RelayCommand::Subscribe { channel_id }) => {
+                        send_subscribe(&mut ws, &state, channel_id, &agent_pubkey_hex, None).await;
+                        state.active_subscriptions.insert(channel_id, channel_sub_id(channel_id));
+                    }
+                    Some(RelayCommand::Unsubscribe { channel_id }) => {
+                        if let Some(sub_id) = state.active_subscriptions.remove(&channel_id) {
+                            let msg = json!(["CLOSE", sub_id]);
+                            if let Ok(text) = serde_json::to_string(&msg) {
+                                let _ = ws.send(Message::Text(text.into())).await;
+                            }
+                            debug!("unsubscribed from channel {channel_id}");
+                        }
+                    }
+                    Some(RelayCommand::Reconnect) => {
+                        // Caller already got None from next_event(); drive reconnect now.
+                        wait_for_reconnect(
+                            &mut ws,
+                            &mut cmd_rx,
+                            &mut state,
+                            &keys,
+                            &relay_url,
+                            api_token.as_deref(),
+                            &agent_pubkey_hex,
+                        )
+                        .await;
+                    }
+                    Some(RelayCommand::Shutdown) | None => {
+                        debug!("background task shutting down");
+                        return;
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// Handle a single WebSocket message in the background task.
+///
+/// Returns `false` if the connection has been lost (Close frame or unrecoverable
+/// error), `true` otherwise.
+async fn handle_ws_message(
+    msg: Message,
+    ws: &mut WsStream,
+    event_tx: &mpsc::Sender<Option<SproutEvent>>,
+    state: &mut BgState,
+    keys: &Keys,
+    relay_url: &str,
+    api_token: Option<&str>,
+) -> bool {
+    match msg {
+        Message::Text(text) => {
+            let relay_msg = match parse_relay_message(&text) {
+                Ok(m) => m,
+                Err(e) => {
+                    warn!("failed to parse relay message: {e} — raw: {text}");
+                    return true;
+                }
+            };
+
+            match relay_msg {
+                RelayMessage::Event {
+                    subscription_id,
+                    event,
+                } => {
+                    if let Some(channel_id) = channel_id_from_sub_id(&subscription_id) {
+                        if state.record_event(channel_id, &event) {
+                            let sprout_event = SproutEvent {
+                                channel_id,
+                                event: *event,
+                            };
+                            match event_tx.try_send(Some(sprout_event)) {
+                                Ok(()) => {}
+                                Err(mpsc::error::TrySendError::Full(_)) => {
+                                    warn!("event channel full — dropping event for channel {channel_id}");
+                                }
+                                Err(mpsc::error::TrySendError::Closed(_)) => {
+                                    // Receiver dropped — shut down.
+                                    return false;
+                                }
+                            }
+                        } else {
+                            debug!("dropping duplicate event for channel {channel_id}");
+                        }
+                    } else {
+                        warn!("received EVENT for unknown subscription {subscription_id}");
+                    }
+                }
+                RelayMessage::Eose { subscription_id } => {
+                    debug!("EOSE for subscription {subscription_id}");
+                }
+                RelayMessage::Notice { message } => {
+                    // Fix 4: NOTICE at warn level.
+                    tracing::warn!("relay NOTICE: {message}");
+                }
+                RelayMessage::Closed {
+                    subscription_id,
+                    message,
+                } => {
+                    warn!("subscription {subscription_id} closed by relay: {message}");
+                }
+                RelayMessage::Auth { challenge } => {
+                    // Fix 5: Handle mid-session AUTH challenge by re-authenticating.
+                    debug!("received mid-session AUTH challenge — re-authenticating");
+                    if let Err(e) =
+                        send_auth_response(ws, &challenge, relay_url, keys, api_token).await
+                    {
+                        warn!("failed to respond to mid-session AUTH challenge: {e}");
+                    }
+                }
+                RelayMessage::Ok {
+                    event_id,
+                    accepted,
+                    message,
+                } => {
+                    debug!("OK for event {event_id}: accepted={accepted} message={message}");
+                }
+            }
+            true
+        }
+        Message::Ping(data) => {
+            if let Err(e) = ws.send(Message::Pong(data)).await {
+                warn!("failed to send pong: {e}");
+                return false;
+            }
+            true
+        }
+        Message::Close(_) => {
+            debug!("relay sent Close frame");
+            false
+        }
+        // Binary, Pong, Frame — ignore
+        _ => true,
+    }
+}
+
+/// Wait for a `Reconnect` command from the caller, then attempt reconnection
+/// with exponential backoff. Resubscribes all active channels with `since`
+/// filters on success.
+async fn wait_for_reconnect(
+    ws: &mut WsStream,
+    cmd_rx: &mut mpsc::Receiver<RelayCommand>,
+    state: &mut BgState,
+    keys: &Keys,
+    relay_url: &str,
+    api_token: Option<&str>,
+    agent_pubkey_hex: &str,
+) {
+    // Drain commands until we get Reconnect (or Shutdown).
+    loop {
+        match cmd_rx.recv().await {
+            Some(RelayCommand::Reconnect) => break,
+            Some(RelayCommand::Shutdown) | None => return,
+            // Ignore Subscribe/Unsubscribe while disconnected — they'll be
+            // resubmitted after reconnect via active_subscriptions.
+            _ => {}
+        }
+    }
+
+    // Attempt reconnection with backoff.
+    let mut delay = Duration::from_secs(1);
+    loop {
+        info!("attempting relay reconnect to {relay_url}…");
+        match do_connect(relay_url, keys, api_token).await {
+            Ok((new_ws, _buffer)) => {
+                *ws = new_ws;
+                info!("relay reconnected to {relay_url}");
+
+                // Resubscribe all active channels with `since` filter.
+                let channels: Vec<Uuid> = state.active_subscriptions.keys().copied().collect();
+                if !channels.is_empty() {
+                    info!("resubscribing to {} channel(s)", channels.len());
+                    for channel_id in channels {
+                        let since = state.last_seen.get(&channel_id).copied();
+                        send_subscribe(ws, state, channel_id, agent_pubkey_hex, since).await;
+                    }
+                }
+
+                return;
+            }
+            Err(e) => {
+                warn!("relay reconnect failed: {e} — retrying in {}s", delay.as_secs());
+                tokio::time::sleep(delay).await;
+                delay = (delay * 2).min(Duration::from_secs(60));
+            }
+        }
+    }
+}
+
+/// Send a NIP-01 REQ for a channel, optionally with a `since` filter.
+async fn send_subscribe(
+    ws: &mut WsStream,
+    _state: &BgState,
+    channel_id: Uuid,
+    agent_pubkey_hex: &str,
+    since: Option<u64>,
+) {
+    let sub_id = channel_sub_id(channel_id);
+
+    let mut filter = json!({
+        "kinds": [
+            KIND_STREAM_MESSAGE,
+            KIND_WORKFLOW_APPROVAL_REQUESTED,
+            KIND_STREAM_REMINDER,
+        ],
+        "#e": [channel_id.to_string()],
+        "#p": [agent_pubkey_hex],
+    });
+
+    // Fix 2: Add `since` filter on reconnect (with 5-second skew buffer).
+    if let Some(ts) = since {
+        filter["since"] = json!(ts.saturating_sub(SINCE_SKEW_SECS));
+    }
+
+    let req = json!(["REQ", sub_id, filter]);
+
+    match serde_json::to_string(&req) {
+        Ok(text) => {
+            if let Err(e) = ws.send(Message::Text(text.into())).await {
+                warn!("failed to send REQ for channel {channel_id}: {e}");
+            } else {
+                debug!("subscribed to channel {channel_id}{}", if since.is_some() { " (with since filter)" } else { "" });
+            }
+        }
+        Err(e) => {
+            warn!("failed to serialize REQ for channel {channel_id}: {e}");
+        }
+    }
+
+}
+
+/// Build and send a NIP-42 AUTH response event.
+async fn send_auth_response(
+    ws: &mut WsStream,
+    challenge: &str,
+    relay_url: &str,
+    keys: &Keys,
+    api_token: Option<&str>,
+) -> Result<(), RelayError> {
+    let relay_nostr_url: NostrUrl = relay_url
+        .parse()
+        .map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?;
+
+    let auth_event = if let Some(token) = api_token {
+        let tags = vec![
+            Tag::parse(&["relay", relay_url])
+                .map_err(|e| RelayError::AuthFailed(e.to_string()))?,
+            Tag::parse(&["challenge", challenge])
+                .map_err(|e| RelayError::AuthFailed(e.to_string()))?,
+            Tag::parse(&["auth_token", token])
+                .map_err(|e| RelayError::AuthFailed(e.to_string()))?,
+        ];
+        EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?
+    } else {
+        EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?
+    };
+
+    let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
+    ws.send(Message::Text(auth_msg.into())).await?;
+    debug!("sent AUTH response for challenge");
+    Ok(())
+}
+
+// ── Free functions ────────────────────────────────────────────────────────────
+
+/// Convert a WebSocket URL to its HTTP equivalent.
+///
+/// `ws://host:port` → `http://host:port`
+/// `wss://host:port` → `https://host:port`
+/// Trailing slashes are stripped.
+pub(crate) fn relay_ws_to_http(url: &str) -> String {
+    url.replace("wss://", "https://")
+        .replace("ws://", "http://")
+        .trim_end_matches('/')
+        .to_string()
+}
+
+/// Build the subscription ID for a channel: `ch-<uuid>`.
+pub(crate) fn channel_sub_id(channel_id: Uuid) -> String {
+    format!("ch-{channel_id}")
+}
+
+/// Extract a channel UUID from a subscription ID of the form `ch-<uuid>`.
+/// Returns `None` if the format doesn't match or the UUID is invalid.
+fn channel_id_from_sub_id(sub_id: &str) -> Option<Uuid> {
+    sub_id
+        .strip_prefix("ch-")
+        .and_then(|s| s.parse::<Uuid>().ok())
+}
+
+/// Apply the appropriate auth header to a reqwest request builder.
+fn apply_auth(
+    builder: reqwest::RequestBuilder,
+    api_token: &Option<String>,
+    keys: &Keys,
+) -> reqwest::RequestBuilder {
+    if let Some(ref token) = api_token {
+        builder.header("Authorization", format!("Bearer {token}"))
+    } else {
+        builder.header("X-Pubkey", keys.public_key().to_hex())
+    }
+}
+
+/// Parse a raw relay text frame into a typed [`RelayMessage`].
+#[allow(private_interfaces)]
+pub(crate) fn parse_relay_message(text: &str) -> Result<RelayMessage, RelayError> {
+    let arr: Vec<Value> = serde_json::from_str(text)?;
+
+    let msg_type = arr
+        .first()
+        .and_then(|v| v.as_str())
+        .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?;
+
+    match msg_type {
+        "EVENT" => {
+            let sub_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            let event: Event = serde_json::from_value(
+                arr.get(2)
+                    .cloned()
+                    .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?,
+            )?;
+            Ok(RelayMessage::Event {
+                subscription_id: sub_id,
+                event: Box::new(event),
+            })
+        }
+        "OK" => {
+            let event_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            let accepted = arr.get(2).and_then(|v| v.as_bool()).unwrap_or(false);
+            let message = arr
+                .get(3)
+                .and_then(|v| v.as_str())
+                .unwrap_or("")
+                .to_string();
+            Ok(RelayMessage::Ok {
+                event_id,
+                accepted,
+                message,
+            })
+        }
+        "EOSE" => {
+            let sub_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            Ok(RelayMessage::Eose {
+                subscription_id: sub_id,
+            })
+        }
+        "CLOSED" => {
+            let sub_id = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            let message = arr
+                .get(2)
+                .and_then(|v| v.as_str())
+                .unwrap_or("")
+                .to_string();
+            Ok(RelayMessage::Closed {
+                subscription_id: sub_id,
+                message,
+            })
+        }
+        "NOTICE" => {
+            let message = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .unwrap_or("")
+                .to_string();
+            Ok(RelayMessage::Notice { message })
+        }
+        "AUTH" => {
+            let challenge = arr
+                .get(1)
+                .and_then(|v| v.as_str())
+                .ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
+                .to_string();
+            Ok(RelayMessage::Auth { challenge })
+        }
+        other => Err(RelayError::UnexpectedMessage(format!(
+            "unknown message type: {other}"
+        ))),
+    }
+}
+
+// ── Connection helpers ────────────────────────────────────────────────────────
+
+/// Perform a single WebSocket connect + NIP-42 auth handshake.
+///
+/// Returns `(ws, buffer)` on success.
+async fn do_connect(
+    relay_url: &str,
+    keys: &Keys,
+    api_token: Option<&str>,
+) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
+    let parsed = relay_url
+        .parse::<url::Url>()
+        .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
+
+    let (ws, _response) = connect_async(parsed.as_str()).await?;
+    debug!("connected to relay at {relay_url}");
+
+    let mut ws = ws;
+    let mut buffer: VecDeque<RelayMessage> = VecDeque::new();
+
+    // ── Step 1: Wait for AUTH challenge ───────────────────────────────────
+    let challenge =
+        wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
+
+    // ── Step 2: Build and send kind:22242 auth event ──────────────────────
+    send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?;
+
+    // ── Step 3: Wait for OK ───────────────────────────────────────────────
+    let event_id = {
+        // We need the event_id that was just sent. Re-derive it by signing again
+        // just to get the ID — but that's wasteful. Instead, parse the last sent
+        // message. Simpler: wait_for_ok accepts any OK (we just sent one event).
+        // The event_id in the OK will match whatever we sent.
+        // We'll accept the first OK we receive.
+        let ok = wait_for_any_ok(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
+        if !ok.accepted {
+            return Err(RelayError::AuthFailed(ok.message));
+        }
+        ok.event_id
+    };
+
+    debug!("NIP-42 authentication successful (event {event_id})");
+    Ok((ws, buffer))
+}
+
+/// Wait for an `AUTH` challenge from the relay, buffering any other messages.
+async fn wait_for_auth_challenge(
+    ws: &mut WsStream,
+    buffer: &mut VecDeque<RelayMessage>,
+    timeout_dur: Duration,
+) -> Result<String, RelayError> {
+    // Check if there's already one buffered.
+    if let Some(idx) = buffer
+        .iter()
+        .position(|m| matches!(m, RelayMessage::Auth { .. }))
+    {
+        if let Some(RelayMessage::Auth { challenge }) = buffer.remove(idx) {
+            return Ok(challenge);
+        }
+    }
+
+    let deadline = tokio::time::Instant::now() + timeout_dur;
+
+    loop {
+        let remaining = deadline
+            .checked_duration_since(tokio::time::Instant::now())
+            .unwrap_or(Duration::ZERO);
+
+        if remaining.is_zero() {
+            return Err(RelayError::NoAuthChallenge);
+        }
+
+        let raw = timeout(remaining, ws.next())
+            .await
+            .map_err(|_| RelayError::NoAuthChallenge)?
+            .ok_or(RelayError::ConnectionClosed)?
+            .map_err(RelayError::WebSocket)?;
+
+        match raw {
+            Message::Text(text) => {
+                let msg = parse_relay_message(&text)?;
+                match msg {
+                    RelayMessage::Auth { challenge } => return Ok(challenge),
+                    other => buffer.push_back(other),
+                }
+            }
+            Message::Ping(data) => {
+                ws.send(Message::Pong(data)).await?;
+            }
+            Message::Close(_) => return Err(RelayError::ConnectionClosed),
+            _ => {}
+        }
+    }
+}
+
+/// Response from an `OK` relay message.
+struct OkResponse {
+    event_id: String,
+    accepted: bool,
+    message: String,
+}
+
+/// Wait for the first `OK` message from the relay (used after sending AUTH).
+async fn wait_for_any_ok(
+    ws: &mut WsStream,
+    buffer: &mut VecDeque<RelayMessage>,
+    timeout_dur: Duration,
+) -> Result<OkResponse, RelayError> {
+    // Check if there's already one buffered.
+    if let Some(idx) = buffer
+        .iter()
+        .position(|m| matches!(m, RelayMessage::Ok { .. }))
+    {
+        if let Some(RelayMessage::Ok {
+            event_id,
+            accepted,
+            message,
+        }) = buffer.remove(idx)
+        {
+            return Ok(OkResponse {
+                event_id,
+                accepted,
+                message,
+            });
+        }
+    }
+
+    let deadline = tokio::time::Instant::now() + timeout_dur;
+
+    loop {
+        let remaining = deadline
+            .checked_duration_since(tokio::time::Instant::now())
+            .unwrap_or(Duration::ZERO);
+
+        if remaining.is_zero() {
+            return Err(RelayError::Timeout);
+        }
+
+        let raw = timeout(remaining, ws.next())
+            .await
+            .map_err(|_| RelayError::Timeout)?
+            .ok_or(RelayError::ConnectionClosed)?
+            .map_err(RelayError::WebSocket)?;
+
+        match raw {
+            Message::Text(text) => {
+                let msg = parse_relay_message(&text)?;
+                match msg {
+                    RelayMessage::Ok {
+                        event_id,
+                        accepted,
+                        message,
+                    } => {
+                        return Ok(OkResponse {
+                            event_id,
+                            accepted,
+                            message,
+                        });
+                    }
+                    other => buffer.push_back(other),
+                }
+            }
+            Message::Ping(data) => {
+                ws.send(Message::Pong(data)).await?;
+            }
+            Message::Close(_) => return Err(RelayError::ConnectionClosed),
+            _ => {}
+        }
+    }
+}
+
+// ── Unit tests ────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── relay_ws_to_http ──────────────────────────────────────────────────────
+
+    #[test]
+    fn relay_ws_to_http_plain() {
+        assert_eq!(
+            relay_ws_to_http("ws://localhost:3000"),
+            "http://localhost:3000"
+        );
+    }
+
+    #[test]
+    fn relay_ws_to_http_secure() {
+        assert_eq!(
+            relay_ws_to_http("wss://relay.example.com"),
+            "https://relay.example.com"
+        );
+    }
+
+    #[test]
+    fn relay_ws_to_http_strips_trailing_slash() {
+        assert_eq!(
+            relay_ws_to_http("ws://localhost:3000/"),
+            "http://localhost:3000"
+        );
+    }
+
+    #[test]
+    fn relay_ws_to_http_with_path() {
+        assert_eq!(
+            relay_ws_to_http("wss://relay.example.com/nostr"),
+            "https://relay.example.com/nostr"
+        );
+    }
+
+    #[test]
+    fn relay_ws_to_http_with_port_and_path() {
+        assert_eq!(
+            relay_ws_to_http("wss://relay.example.com:4000/ws"),
+            "https://relay.example.com:4000/ws"
+        );
+    }
+
+    // ── channel_sub_id ────────────────────────────────────────────────────────
+
+    #[test]
+    fn channel_sub_id_format() {
+        let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
+        assert_eq!(
+            channel_sub_id(uuid),
+            "ch-550e8400-e29b-41d4-a716-446655440000"
+        );
+    }
+
+    #[test]
+    fn channel_id_from_sub_id_roundtrip() {
+        let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
+        let sub_id = channel_sub_id(uuid);
+        let recovered = channel_id_from_sub_id(&sub_id).unwrap();
+        assert_eq!(recovered, uuid);
+    }
+
+    #[test]
+    fn channel_id_from_sub_id_invalid_prefix() {
+        assert!(channel_id_from_sub_id("sub-550e8400-e29b-41d4-a716-446655440000").is_none());
+    }
+
+    #[test]
+    fn channel_id_from_sub_id_invalid_uuid() {
+        assert!(channel_id_from_sub_id("ch-not-a-uuid").is_none());
+    }
+
+    #[test]
+    fn channel_id_from_sub_id_empty() {
+        assert!(channel_id_from_sub_id("").is_none());
+    }
+
+    // ── parse_relay_message ───────────────────────────────────────────────────
+
+    #[test]
+    fn parse_ok_accepted() {
+        let text = r#"["OK","abc123",true,""]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Ok {
+                event_id,
+                accepted,
+                message,
+            } => {
+                assert_eq!(event_id, "abc123");
+                assert!(accepted);
+                assert_eq!(message, "");
+            }
+            _ => panic!("expected Ok"),
+        }
+    }
+
+    #[test]
+    fn parse_ok_rejected() {
+        let text = r#"["OK","abc123",false,"blocked: spam"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Ok {
+                event_id,
+                accepted,
+                message,
+            } => {
+                assert_eq!(event_id, "abc123");
+                assert!(!accepted);
+                assert_eq!(message, "blocked: spam");
+            }
+            _ => panic!("expected Ok"),
+        }
+    }
+
+    #[test]
+    fn parse_eose() {
+        let text = r#"["EOSE","sub-1"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Eose { subscription_id } => {
+                assert_eq!(subscription_id, "sub-1");
+            }
+            _ => panic!("expected Eose"),
+        }
+    }
+
+    #[test]
+    fn parse_notice() {
+        let text = r#"["NOTICE","hello from relay"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Notice { message } => {
+                assert_eq!(message, "hello from relay");
+            }
+            _ => panic!("expected Notice"),
+        }
+    }
+
+    #[test]
+    fn parse_notice_empty() {
+        let text = r#"["NOTICE"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Notice { message } => {
+                assert_eq!(message, "");
+            }
+            _ => panic!("expected Notice"),
+        }
+    }
+
+    #[test]
+    fn parse_auth() {
+        let text = r#"["AUTH","some-challenge-string"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Auth { challenge } => {
+                assert_eq!(challenge, "some-challenge-string");
+            }
+            _ => panic!("expected Auth"),
+        }
+    }
+
+    #[test]
+    fn parse_closed() {
+        let text = r#"["CLOSED","sub-2","error: rate-limited"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Closed {
+                subscription_id,
+                message,
+            } => {
+                assert_eq!(subscription_id, "sub-2");
+                assert_eq!(message, "error: rate-limited");
+            }
+            _ => panic!("expected Closed"),
+        }
+    }
+
+    #[test]
+    fn parse_closed_no_message() {
+        let text = r#"["CLOSED","sub-3"]"#;
+        let msg = parse_relay_message(text).unwrap();
+        match msg {
+            RelayMessage::Closed {
+                subscription_id,
+                message,
+            } => {
+                assert_eq!(subscription_id, "sub-3");
+                assert_eq!(message, "");
+            }
+            _ => panic!("expected Closed"),
+        }
+    }
+
+    #[test]
+    fn parse_unknown_type_returns_error() {
+        let text = r#"["UNKNOWN","data"]"#;
+        let result = parse_relay_message(text);
+        assert!(result.is_err());
+        match result.unwrap_err() {
+            RelayError::UnexpectedMessage(msg) => {
+                assert!(msg.contains("unknown message type"));
+            }
+            e => panic!("expected UnexpectedMessage, got {e:?}"),
+        }
+    }
+
+    #[test]
+    fn parse_invalid_json_returns_error() {
+        let text = "not json at all";
+        let result = parse_relay_message(text);
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), RelayError::Json(_)));
+    }
+
+    #[test]
+    fn parse_empty_array_returns_error() {
+        let text = "[]";
+        let result = parse_relay_message(text);
+        assert!(result.is_err());
+        assert!(matches!(
+            result.unwrap_err(),
+            RelayError::UnexpectedMessage(_)
+        ));
+    }
+
+    #[test]
+    fn parse_auth_missing_challenge_returns_error() {
+        let text = r#"["AUTH"]"#;
+        let result = parse_relay_message(text);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn parse_eose_missing_sub_id_returns_error() {
+        let text = r#"["EOSE"]"#;
+        let result = parse_relay_message(text);
+        assert!(result.is_err());
+    }
+
+    // ── channel_sub_id subscription format ───────────────────────────────────
+
+    #[test]
+    fn subscription_id_starts_with_ch_prefix() {
+        let uuid = Uuid::new_v4();
+        let sub_id = channel_sub_id(uuid);
+        assert!(sub_id.starts_with("ch-"));
+    }
+
+    #[test]
+    fn subscription_id_contains_full_uuid() {
+        let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
+        let sub_id = channel_sub_id(uuid);
+        assert_eq!(sub_id, "ch-12345678-1234-5678-1234-567812345678");
+    }
+
+    // ── BgState: seen_ids deduplication ──────────────────────────────────────
+
+    /// Build a real signed Nostr event for testing BgState.
+    ///
+    /// Uses `custom_created_at` so tests can control the timestamp.
+    /// The event ID is determined by the nostr signing process — we don't
+    /// control it, but we return it so callers can use it for dedup tests.
+    fn make_test_event(keys: &nostr::Keys, created_at_secs: u64) -> Event {
+        let ts = nostr::Timestamp::from(created_at_secs);
+        EventBuilder::new(nostr::Kind::TextNote, "test", [])
+            .custom_created_at(ts)
+            .sign_with_keys(keys)
+            .expect("signing should succeed")
+    }
+
+    #[test]
+    fn bg_state_dedup_first_event_accepted() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+        let keys = nostr::Keys::generate();
+        let event = make_test_event(&keys, 1_000_000);
+        assert!(state.record_event(channel_id, &event), "first event should be accepted");
+    }
+
+    #[test]
+    fn bg_state_dedup_duplicate_rejected() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+        let keys = nostr::Keys::generate();
+        let event = make_test_event(&keys, 1_000_000);
+        assert!(state.record_event(channel_id, &event), "first should be accepted");
+        assert!(!state.record_event(channel_id, &event), "duplicate should be rejected");
+    }
+
+    #[test]
+    fn bg_state_dedup_different_ids_both_accepted() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+        // Two different keys → two different event IDs.
+        let keys1 = nostr::Keys::generate();
+        let keys2 = nostr::Keys::generate();
+        let event1 = make_test_event(&keys1, 1_000_000);
+        let event2 = make_test_event(&keys2, 1_000_001);
+        assert!(state.record_event(channel_id, &event1));
+        assert!(state.record_event(channel_id, &event2));
+    }
+
+    // ── BgState: last_seen tracking ───────────────────────────────────────────
+
+    #[test]
+    fn bg_state_last_seen_set_on_first_event() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+        let keys = nostr::Keys::generate();
+        let event = make_test_event(&keys, 1_700_000);
+        state.record_event(channel_id, &event);
+        assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_700_000));
+    }
+
+    #[test]
+    fn bg_state_last_seen_advances_on_newer_event() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+        let keys1 = nostr::Keys::generate();
+        let keys2 = nostr::Keys::generate();
+        let event1 = make_test_event(&keys1, 1_700_000);
+        let event2 = make_test_event(&keys2, 1_800_000);
+        state.record_event(channel_id, &event1);
+        state.record_event(channel_id, &event2);
+        assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_800_000));
+    }
+
+    #[test]
+    fn bg_state_last_seen_does_not_regress_on_older_event() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+        let keys1 = nostr::Keys::generate();
+        let keys2 = nostr::Keys::generate();
+        let event_new = make_test_event(&keys1, 1_800_000);
+        let event_old = make_test_event(&keys2, 1_700_000);
+        state.record_event(channel_id, &event_new);
+        state.record_event(channel_id, &event_old);
+        // last_seen should remain at the higher timestamp
+        assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_800_000));
+    }
+
+    #[test]
+    fn bg_state_last_seen_independent_per_channel() {
+        let mut state = BgState::new();
+        let ch1 = Uuid::new_v4();
+        let ch2 = Uuid::new_v4();
+        let keys1 = nostr::Keys::generate();
+        let keys2 = nostr::Keys::generate();
+        let event1 = make_test_event(&keys1, 1_000_000);
+        let event2 = make_test_event(&keys2, 2_000_000);
+        state.record_event(ch1, &event1);
+        state.record_event(ch2, &event2);
+        assert_eq!(state.last_seen.get(&ch1).copied(), Some(1_000_000));
+        assert_eq!(state.last_seen.get(&ch2).copied(), Some(2_000_000));
+    }
+
+    #[test]
+    fn bg_state_seen_ids_cleared_at_limit() {
+        let mut state = BgState::new();
+        let channel_id = Uuid::new_v4();
+
+        // Pre-populate seen_ids to just below the threshold (12_000 - 1).
+        // Use synthetic hex strings — we're testing the clear logic, not signing.
+        for i in 0u64..11_999 {
+            state.seen_ids.insert(format!("{:0>64x}", i));
+        }
+        assert_eq!(state.seen_ids.len(), 11_999);
+
+        // Now insert two real events. The first will bring us to 12_000 (no
+        // clear yet), the second will push us to 12_001 and trigger the clear.
+        let keys = nostr::Keys::generate();
+        let event1 = make_test_event(&keys, 1_000_000);
+        let keys2 = nostr::Keys::generate();
+        let event2 = make_test_event(&keys2, 1_000_001);
+
+        // First insert: 12_000 entries — no clear triggered yet.
+        state.record_event(channel_id, &event1);
+        assert_eq!(state.seen_ids.len(), 12_000, "should be at 12_000 before clear");
+
+        // Second insert: 12_001 entries — triggers clear, then re-inserts.
+        state.record_event(channel_id, &event2);
+        // After clear + re-insert of event2, seen_ids should be very small.
+        assert!(
+            state.seen_ids.len() < 12_000,
+            "seen_ids should have been cleared, got {}",
+            state.seen_ids.len()
+        );
+    }
+}
diff --git a/crates/sprout-test-client/Cargo.toml b/crates/sprout-test-client/Cargo.toml
index 70be79cb10..6c1ecc6273 100644
--- a/crates/sprout-test-client/Cargo.toml
+++ b/crates/sprout-test-client/Cargo.toml
@@ -8,6 +8,7 @@ repository.workspace = true
 description = "Integration test client and E2E test suite for Sprout"
 
 [dependencies]
+anyhow = { workspace = true }
 sprout-core = { workspace = true }
 sprout-mcp = { workspace = true }
 nostr = { workspace = true }
diff --git a/crates/sprout-test-client/src/bin/mention.rs b/crates/sprout-test-client/src/bin/mention.rs
new file mode 100644
index 0000000000..a3516ace90
--- /dev/null
+++ b/crates/sprout-test-client/src/bin/mention.rs
@@ -0,0 +1,36 @@
+//! Send an @mention event to a Sprout channel targeting a specific pubkey.
+//! Usage: mention <channel_uuid> <target_pubkey_hex> <message>
+
+use nostr::{EventBuilder, Keys, Kind, Tag};
+use sprout_test_client::SproutTestClient;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    let args: Vec<String> = std::env::args().collect();
+    if args.len() < 4 {
+        eprintln!("Usage: mention <channel_uuid> <target_pubkey_hex> <message>");
+        std::process::exit(1);
+    }
+    let channel_id = &args[1];
+    let target_pubkey = &args[2];
+    let message = &args[3];
+
+    let url = std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".into());
+    let keys = Keys::generate();
+    println!("Sender pubkey: {}", keys.public_key().to_hex());
+
+    let mut client = SproutTestClient::connect(&url, &keys).await?;
+
+    let e_tag = Tag::parse(&["e", channel_id])?;
+    let p_tag = Tag::parse(&["p", target_pubkey])?;
+    let event = EventBuilder::new(Kind::Custom(40001), message, [e_tag, p_tag])
+        .sign_with_keys(&keys)?;
+
+    let ok = client.send_event(event).await?;
+    if ok.accepted {
+        println!("✅ @mention sent: {}", ok.event_id);
+    } else {
+        eprintln!("❌ Rejected: {}", ok.message);
+    }
+    Ok(())
+}

From 5e04a0c4ee6b70c9e715bf6a3731ad9839699770 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 21:36:53 -0400
Subject: [PATCH 2/8] docs: add sprout-acp integration testing guide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Comprehensive TESTING.md covering:
- Prerequisites (Docker, relay, binaries, test keys)
- Quick start (5-minute goose smoke test)
- Agent-specific setup for all three supported agents:
  - goose (native ACP)
  - codex (via codex-acp adapter from Zed)
  - claude code (via claude-agent-acp adapter from Zed)
- 8 test scenarios with exact commands and verification steps:
  A. Basic @mention → reply
  B. Multi-event batching
  C. Agent crash recovery
  D. Relay disconnect recovery
  E. Turn timeout + cancel
  F. Permission auto-approve
  G. Channel discovery
  H. Concurrent channel FIFO fairness
- Verification commands (DB queries, log patterns, process checks)
- Troubleshooting guide (10 common failure modes)
- CI integration sketch (aspirational)
- Actual test results: all 3 agents passed E2E on 2026-03-10
---
 crates/sprout-acp/TESTING.md | 904 +++++++++++++++++++++++++++++++++++
 1 file changed, 904 insertions(+)
 create mode 100644 crates/sprout-acp/TESTING.md

diff --git a/crates/sprout-acp/TESTING.md b/crates/sprout-acp/TESTING.md
new file mode 100644
index 0000000000..8ff3b78fb7
--- /dev/null
+++ b/crates/sprout-acp/TESTING.md
@@ -0,0 +1,904 @@
+# sprout-acp Integration Testing Guide
+
+## Overview
+
+`sprout-acp` is the ACP harness that bridges Sprout relay events to AI agents. It:
+
+- Connects to a Sprout relay via NIP-01 WebSocket with NIP-42 auth
+- Listens for `@mention` events (kind 40001) targeting the agent's pubkey
+- Queues events per channel, enforcing one prompt-in-flight globally
+- Sends batched prompts to an ACP agent over stdio JSON-RPC 2.0
+- The agent uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to respond
+
+**Architecture:**
+
+```
+Sprout Relay ──WS (NIP-01)──→ sprout-acp harness ──stdio (JSON-RPC)──→ ACP Agent
+                                                                          │
+                                                                     Sprout MCP
+                                                                    (16 tools)
+```
+
+This document covers manual integration testing of the full pipeline. For unit tests, run `cargo test` in the crate root.
+
+---
+
+## Prerequisites
+
+### 1. Docker Services
+
+```bash
+cd /path/to/sprout2
+docker compose up -d
+```
+
+Required services: MySQL, Redis, Typesense, Keycloak. Verify:
+
+```bash
+docker compose ps
+```
+
+All services should be `Up`.
+
+### 2. Relay
+
+Start `sprout-relay` in a detached screen session:
+
+```bash
+screen -dmS relay just relay
+```
+
+Verify it's listening:
+
+```bash
+screen -r relay   # Ctrl-A D to detach
+# or:
+curl -s http://localhost:3000/health || echo "relay not up"
+```
+
+### 3. Build Binaries
+
+```bash
+cargo build --release -p sprout-acp -p sprout-mcp-server
+```
+
+Ensure `target/release/sprout-acp`, `target/release/sprout-mcp-server`, and `target/release/mention` are present:
+
+```bash
+ls -la target/release/sprout-acp target/release/sprout-mcp-server target/release/mention
+```
+
+Add to PATH for convenience:
+
+```bash
+export PATH="$PWD/target/release:$PATH"
+```
+
+### 4. Test Keys
+
+These keys are for local development only — never use in production.
+
+```bash
+export HARNESS_NSEC=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5
+export AGENT_NSEC=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
+export AGENT_PUBKEY_HEX=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb
+export AGENTS_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301
+```
+
+### 5. MySQL Access
+
+```bash
+# Connect via docker exec (mysql client may not be installed locally)
+docker exec -it sprout-mysql mysql -u sprout -psprout_dev sprout
+```
+
+---
+
+## Quick Start (5 Minutes)
+
+Minimal path: run harness with goose, send a test `@mention`, verify reply.
+
+### Step 1: Set Environment
+
+```bash
+export PATH="$PWD/target/release:$PATH"
+export SPROUT_ACP_PRIVATE_KEY=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5
+export SPROUT_AGENT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
+export SPROUT_RELAY_URL=ws://localhost:3000
+export SPROUT_ACP_AGENT_COMMAND=goose
+export SPROUT_ACP_AGENT_ARGS=acp
+export GOOSE_MODE=auto
+```
+
+### Step 2: Start Harness
+
+```bash
+screen -dmS harness sprout-acp
+screen -r harness   # Ctrl-A D to detach
+```
+
+Expected log output:
+```
+sprout-acp starting: relay=ws://localhost:3000 harness_pubkey=... agent_pubkey=ae670a...
+agent initialized: ...
+connected to relay at ws://localhost:3000
+discovered N channel(s)
+subscribed to channel 94a444a4-c0a3-5966-ab05-530c6ddc2301
+```
+
+### Step 3: Send a Test @mention
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb "Hello agent, reply with 'pong'"
+```
+
+### Step 4: Verify Reply
+
+Check harness logs:
+
+```bash
+screen -r harness
+# Look for: "turn complete for channel 94a444a4-...: end_turn"
+```
+
+Check MySQL for agent reply:
+
+```sql
+SELECT HEX(e.pubkey) as sender, e.kind, SUBSTRING(e.content, 1, 200) as body, e.created_at
+FROM events e
+WHERE e.kind = 40001
+ORDER BY e.created_at DESC LIMIT 10;
+```
+
+The agent's pubkey (`ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb`) should appear as `sender` with a reply body.
+
+---
+
+## Agent Setup
+
+### goose (Native ACP)
+
+goose supports ACP natively. No adapter needed.
+
+```bash
+export SPROUT_ACP_AGENT_COMMAND=goose
+export SPROUT_ACP_AGENT_ARGS=acp
+export GOOSE_MODE=auto   # Disables interactive permission prompts
+```
+
+**Gotchas:**
+- `GOOSE_MODE=auto` is required — without it, goose may pause waiting for user approval on tool calls
+- Ensure `goose` is on `$PATH` and configured with a valid provider/model
+
+Verify goose is working standalone:
+
+```bash
+echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | goose acp
+```
+
+---
+
+### codex (via codex-acp adapter)
+
+codex-acp is Zed's ACP wrapper around OpenAI Codex. Requires building from source.
+
+**Build:**
+
+```bash
+# Requires Rust 1.91+ — use hermit if needed
+cd /path/to/codex-acp
+cargo build --release
+# Binary at: /path/to/codex-acp/target/release/codex-acp
+```
+
+**Configure:**
+
+```bash
+export OPENAI_API_KEY=sk-...   # or CODEX_API_KEY
+export SPROUT_ACP_AGENT_COMMAND=/path/to/codex-acp/target/release/codex-acp
+export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
+```
+
+The `-c permissions.approval_policy="never"` arg disables codex's approval prompts (equivalent to goose's `GOOSE_MODE=auto`).
+
+**Gotchas:**
+- Use the full absolute path for `SPROUT_ACP_AGENT_COMMAND` — codex-acp is not typically on `$PATH`
+- `SPROUT_ACP_AGENT_ARGS` is comma-separated; the config parser splits on `,`
+- Repo: https://github.com/zed-industries/codex-acp
+
+---
+
+### claude code (via claude-agent-acp adapter)
+
+claude-agent-acp is Zed's ACP wrapper around the Claude Agent SDK. Requires Node.js.
+
+**Build:**
+
+```bash
+cd /path/to/claude-agent-acp
+npm install
+npm run build
+# Entry point at: /path/to/claude-agent-acp/dist/index.js
+```
+
+**Configure:**
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+export SPROUT_ACP_AGENT_COMMAND=node
+export SPROUT_ACP_AGENT_ARGS=/path/to/claude-agent-acp/dist/index.js
+```
+
+**Gotchas:**
+- `node` must be on `$PATH`
+- The `dist/index.js` path must be absolute
+- Repo: https://github.com/zed-industries/claude-agent-acp
+
+---
+
+## Test Scenarios
+
+### Scenario A: Basic @mention → Agent Replies
+
+**Description:** Single @mention event triggers a prompt; agent replies via `send_message`.
+
+**Steps:**
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "What is 2 + 2?"
+```
+
+**Expected behavior:**
+- Harness logs: `prompting agent for channel 94a444a4-... (session ..., 1 event(s))`
+- Agent calls `send_message` MCP tool with a reply
+- Harness logs: `turn complete for channel 94a444a4-...: end_turn`
+
+**Verify:**
+
+```sql
+SELECT HEX(e.pubkey) as sender, SUBSTRING(e.content, 1, 200) as body, e.created_at
+FROM events e
+WHERE e.kind = 40001
+  AND HEX(e.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb'
+ORDER BY e.created_at DESC LIMIT 5;
+```
+
+---
+
+### Scenario B: Multi-Event Batch (Rapid @mentions)
+
+**Description:** Three @mentions sent in rapid succession should be batched into a single prompt (queue drains all pending events for a channel at once).
+
+**Steps:**
+
+```bash
+for i in 1 2 3; do
+  mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+    ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+    "Batch message $i" &
+done
+wait
+```
+
+**Expected behavior:**
+- Harness logs show `(session ..., 3 event(s))` or `(2 event(s))` + `(1 event(s))` depending on timing
+- Only **one** `session/prompt` call fires per flush cycle
+- Agent receives all messages in a single prompt text
+
+**Verify:**
+
+Check harness log for batch size:
+
+```bash
+screen -r harness
+# Look for: "prompting agent for channel ... (session ..., N event(s))"
+```
+
+If N > 1, batching worked. If you see three separate `prompting agent` lines, the events arrived too slowly — try sending them faster or add a brief sleep before the harness starts.
+
+---
+
+### Scenario C: Agent Crash Recovery
+
+**Description:** Kill the agent subprocess; harness should detect exit, respawn, and process the next event.
+
+**Steps:**
+
+1. While harness is running, find the agent PID:
+
+```bash
+# In a separate terminal
+pgrep -f "goose acp"   # or codex-acp, node, etc.
+```
+
+2. Kill it:
+
+```bash
+kill -9 <agent-pid>
+```
+
+3. Send a new @mention:
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "Are you still alive?"
+```
+
+**Expected behavior:**
+- Harness logs: `agent process exited — respawning`
+- Harness logs: `agent respawned successfully`
+- Harness logs: `agent initialized: ...`
+- The new @mention is processed and agent replies
+
+**Verify:**
+
+```bash
+screen -r harness
+# Sequence: "agent process exited" → "agent respawned successfully" → "turn complete"
+```
+
+**Note:** Events in-flight when the agent crashes are requeued — they will be retried after respawn.
+
+---
+
+### Scenario D: Relay Disconnect Recovery
+
+**Description:** Kill the relay; harness should detect disconnect and reconnect automatically.
+
+**Steps:**
+
+1. Stop the relay:
+
+```bash
+screen -r relay
+# Ctrl-C to stop, then Ctrl-A D to detach
+```
+
+2. Watch harness logs:
+
+```bash
+screen -r harness
+# Look for: "relay connection lost — reconnecting"
+```
+
+3. Restart the relay:
+
+```bash
+screen -dmS relay just relay
+```
+
+**Expected behavior:**
+- Harness logs: `relay connection lost — reconnecting`
+- Harness logs: `relay reconnect failed: ... — retrying in 5s` (repeats until relay is back)
+- After relay restarts: `relay reconnected`
+- Harness re-subscribes to channels and resumes normal operation
+
+4. Verify recovery by sending a new @mention:
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "Post-reconnect test"
+```
+
+**Verify:**
+
+```bash
+screen -r harness
+# Look for: "relay reconnected" followed by "turn complete"
+```
+
+---
+
+### Scenario E: Turn Timeout
+
+**Description:** Set a very short timeout; send a prompt that will take longer than the timeout. Harness should cancel the turn and log a warning.
+
+**Steps:**
+
+1. Restart harness with short timeout:
+
+```bash
+screen -S harness -X quit
+SPROUT_ACP_TURN_TIMEOUT=5 \
+SPROUT_ACP_PRIVATE_KEY=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5 \
+SPROUT_AGENT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
+SPROUT_ACP_AGENT_COMMAND=goose \
+SPROUT_ACP_AGENT_ARGS=acp \
+GOOSE_MODE=auto \
+screen -dmS harness sprout-acp
+```
+
+2. Send a prompt that requires significant computation:
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "Write a detailed 500-word essay on the history of computing, then list 50 prime numbers"
+```
+
+**Expected behavior:**
+- After ~5 seconds: `turn timeout (5s) for channel 94a444a4-... — cancelling`
+- Harness sends `session/cancel` to agent
+- Harness logs: `turn cancelled for channel 94a444a4-...`
+- Harness continues running and accepts new events
+
+**Verify:**
+
+```bash
+screen -r harness
+# Look for: "turn timeout (5s) for channel ... — cancelling"
+# Then: "turn cancelled for channel ..."
+```
+
+**Note:** Timed-out events are NOT requeued — the turn was attempted. Reset timeout to 300 after this test.
+
+---
+
+### Scenario F: Permission Handling (Auto-Approve)
+
+**Description:** Verify that `GOOSE_MODE=auto` prevents the agent from pausing for permission prompts on tool calls.
+
+**Steps:**
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "Use your tools to get the last 5 messages from this channel and summarize them"
+```
+
+**Expected behavior:**
+- Agent calls `get_channel_history` MCP tool without pausing
+- No permission prompts appear in harness stdout
+- Turn completes with `end_turn`
+
+**Verify:**
+
+```bash
+screen -r harness
+# Should NOT see any "waiting for approval" or "permission" lines
+# Should see: "turn complete for channel ...: end_turn"
+```
+
+If the agent hangs indefinitely, `GOOSE_MODE=auto` may not be set. Kill the harness, set `GOOSE_MODE=auto`, and restart.
+
+---
+
+### Scenario G: Channel Discovery
+
+**Description:** Verify the harness discovers and subscribes to the agent's channels on startup.
+
+**Steps:**
+
+1. Start harness fresh (no prior subscriptions):
+
+```bash
+screen -S harness -X quit
+screen -dmS harness sprout-acp
+screen -r harness
+```
+
+**Expected behavior:**
+
+```
+discovered N channel(s)
+subscribed to channel 94a444a4-c0a3-5966-ab05-530c6ddc2301
+```
+
+**Verify:**
+
+The harness calls the relay's REST API to discover channels associated with the agent's pubkey (`ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb`). If `discovered 0 channel(s)` appears, the agent may not be a member of any channels — check the Sprout admin UI or database.
+
+```sql
+-- Check agent channel membership
+SELECT c.uuid, c.name
+FROM channels c
+JOIN channel_members cm ON cm.channel_id = c.id
+JOIN nostr_keys nk ON nk.id = cm.nostr_key_id
+WHERE HEX(nk.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
+```
+
+---
+
+### Scenario H: Concurrent Channels (FIFO Fairness)
+
+**Description:** Send @mentions to two different channels; verify the harness processes them in FIFO order (oldest pending event first) with one prompt in flight at a time.
+
+**Setup:** You need a second channel UUID. Create one in the Sprout admin UI or use an existing one. Replace `<CHANNEL_B_UUID>` below.
+
+**Steps:**
+
+```bash
+# Send to channel A first
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "Channel A message"
+
+# Immediately send to channel B
+mention <CHANNEL_B_UUID> \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "Channel B message"
+```
+
+**Expected behavior:**
+- Channel A prompt fires first (oldest pending event)
+- Channel B prompt fires after Channel A's turn completes
+- No overlap — only one `session/prompt` in flight at a time
+
+**Verify:**
+
+```bash
+screen -r harness
+# Look for sequential "prompting agent for channel ..." lines
+# Channel A should appear before Channel B
+# No two "prompting agent" lines without a "turn complete" between them
+```
+
+---
+
+## Verification Commands
+
+### Harness Log Patterns
+
+```bash
+# Attach to harness screen session
+screen -r harness
+
+# Grep harness log (if redirected to file)
+grep "turn complete" /tmp/harness.log
+grep "turn timeout" /tmp/harness.log
+grep "agent process exited" /tmp/harness.log
+grep "relay connection lost" /tmp/harness.log
+```
+
+To capture harness output to a file:
+
+```bash
+screen -S harness -X quit
+screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
+```
+
+### Database Queries
+
+```sql
+-- All recent events (last 10)
+SELECT HEX(e.pubkey) as sender, e.kind,
+       SUBSTRING(e.content, 1, 200) as body,
+       e.created_at
+FROM events e
+ORDER BY e.created_at DESC LIMIT 10;
+
+-- Agent reply events only
+SELECT HEX(e.pubkey) as sender, e.kind,
+       SUBSTRING(e.content, 1, 200) as body,
+       e.created_at
+FROM events e
+WHERE e.kind = 40001
+  AND HEX(e.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb'
+ORDER BY e.created_at DESC LIMIT 10;
+
+-- Events in a specific channel
+SELECT HEX(e.pubkey) as sender,
+       SUBSTRING(e.content, 1, 200) as body,
+       e.created_at
+FROM events e
+JOIN event_tags et ON et.event_id = e.id
+WHERE et.tag_name = 'channel'
+  AND et.tag_value = '94a444a4-c0a3-5966-ab05-530c6ddc2301'
+ORDER BY e.created_at DESC LIMIT 20;
+
+-- Agent channel membership
+SELECT c.uuid, c.name
+FROM channels c
+JOIN channel_members cm ON cm.channel_id = c.id
+JOIN nostr_keys nk ON nk.id = cm.nostr_key_id
+WHERE HEX(nk.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
+```
+
+### Relay Health
+
+```bash
+# Check relay is accepting WebSocket connections
+curl -i -N -H "Connection: Upgrade" \
+  -H "Upgrade: websocket" \
+  -H "Sec-WebSocket-Version: 13" \
+  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
+  http://localhost:3000/
+
+# Check relay HTTP health (if endpoint exists)
+curl -s http://localhost:3000/health
+```
+
+### Process Status
+
+```bash
+# Check harness is running
+pgrep -a sprout-acp
+
+# Check agent subprocess
+pgrep -a goose       # for goose
+pgrep -a codex-acp   # for codex
+pgrep -fa "node.*claude-agent-acp"  # for claude code
+
+# Check all screen sessions
+screen -ls
+```
+
+---
+
+## Troubleshooting
+
+### Harness exits immediately with "configuration error"
+
+**Cause:** Missing required env vars.
+
+**Fix:** Ensure both `SPROUT_ACP_PRIVATE_KEY` and `SPROUT_AGENT_PRIVATE_KEY` are set:
+
+```bash
+echo $SPROUT_ACP_PRIVATE_KEY
+echo $SPROUT_AGENT_PRIVATE_KEY
+```
+
+Both must be valid `nsec1...` bech32 strings.
+
+---
+
+### "relay connect error" on startup
+
+**Cause:** Relay not running or wrong URL.
+
+**Fix:**
+
+```bash
+screen -ls | grep relay
+# If no relay session: screen -dmS relay just relay
+# Check URL: echo $SPROUT_RELAY_URL (default: ws://localhost:3000)
+```
+
+---
+
+### "channel discovery error" on startup
+
+**Cause:** Relay REST API unreachable, or agent pubkey not registered.
+
+**Fix:**
+
+```bash
+# Verify relay is up
+curl -s http://localhost:3000/health
+
+# Check agent is in at least one channel (see DB query in Scenario G)
+```
+
+---
+
+### "failed to spawn agent"
+
+**Cause:** Agent binary not found or not executable.
+
+**Fix:**
+
+```bash
+which goose          # for goose
+ls -la $SPROUT_ACP_AGENT_COMMAND  # for codex-acp or node path
+```
+
+Ensure the binary is on `$PATH` or the full path is correct.
+
+---
+
+### Agent hangs, turn never completes
+
+**Cause:** Agent waiting for permission approval (`GOOSE_MODE` not set, or codex approval policy not configured).
+
+**Fix:**
+
+```bash
+# For goose:
+export GOOSE_MODE=auto
+
+# For codex:
+export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
+```
+
+Restart harness after setting.
+
+---
+
+### "discovered 0 channel(s)"
+
+**Cause:** Agent pubkey is not a member of any channels on the relay.
+
+**Fix:** Add the agent to a channel via the Sprout admin UI, or check the DB:
+
+```sql
+SELECT c.uuid, c.name
+FROM channels c
+JOIN channel_members cm ON cm.channel_id = c.id
+JOIN nostr_keys nk ON nk.id = cm.nostr_key_id
+WHERE HEX(nk.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
+```
+
+---
+
+### No agent reply in DB after @mention
+
+**Checklist:**
+
+1. Is the harness running? `pgrep -a sprout-acp`
+2. Is the harness subscribed to the channel? Check startup logs for `subscribed to channel 94a444a4-...`
+3. Did the `mention` command use the correct pubkey hex? (`ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb`)
+4. Check harness logs for errors: `screen -r harness`
+5. Did the agent have the MCP tools configured? Look for `sprout-mcp` in agent init logs.
+
+---
+
+### MCP tool calls failing
+
+**Cause:** `sprout-mcp-server` binary not found, or wrong relay URL/keys passed to MCP.
+
+**Fix:**
+
+```bash
+# Verify MCP binary
+which sprout-mcp-server
+# or check SPROUT_ACP_MCP_COMMAND
+echo $SPROUT_ACP_MCP_COMMAND
+
+# Test MCP server directly
+SPROUT_RELAY_URL=ws://localhost:3000 \
+SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
+sprout-mcp-server
+```
+
+---
+
+### codex-acp build fails
+
+**Cause:** Rust version too old (requires 1.91+).
+
+**Fix:**
+
+```bash
+# Using hermit (if available in repo)
+cd /path/to/codex-acp
+bin/hermit env
+cargo build --release
+```
+
+---
+
+## Test Results (2026-03-10)
+
+All three agents tested end-to-end against a local relay with Docker services running.
+
+| Agent | Adapter | Command | Test Prompt | Reply | Result |
+|-------|---------|---------|-------------|-------|--------|
+| **goose** | Native ACP (`goose acp`) | `goose acp` | "What is the capital of France? Reply with just the city name." | `Paris` | ✅ PASS |
+| **codex** | codex-acp (Zed adapter) | `codex-acp -c permissions.approval_policy="never"` | "What is 7 times 8? Reply with just the number." | `56` | ✅ PASS |
+| **claude code** | claude-agent-acp (Zed adapter) | `node dist/index.js` | "What is the square root of 144? Reply with just the number." | `12` | ✅ PASS |
+
+### Observations
+
+- **Session creation**: All agents created sessions in <2s. goose and claude used string UUIDs; codex used a UUID with timestamp prefix.
+- **Turn latency**: goose ~4s, codex ~6s, claude ~3s for simple math/fact prompts.
+- **MCP tool usage**: All agents successfully called `send_message` via the Sprout MCP server to post replies.
+- **Stale event handling**: On startup, each harness instance picked up and processed stale @mention events from prior test runs. All handled gracefully.
+- **Adapter build requirements**:
+  - codex-acp requires Rust 1.91+ (use hermit: `REPOS/sprout2/bin/cargo`)
+  - claude-agent-acp requires Node.js (`npm install && npm run build`)
+
+### DB Verification Query Used
+
+```bash
+docker exec sprout-mysql mysql -u sprout -psprout_dev sprout -e "
+SELECT
+    SUBSTRING(HEX(pubkey), 1, 16) as sender_prefix,
+    kind,
+    SUBSTRING(content, 1, 120) as body,
+    created_at
+FROM events
+WHERE kind = 40001
+ORDER BY created_at DESC
+LIMIT 10;
+"
+```
+
+---
+
+## CI Integration (Aspirational)
+
+The test scenarios above are currently manual. This section outlines how to automate them.
+
+### Recommended Approach
+
+A CI integration test suite would:
+
+1. **Spin up Docker services** — `docker compose up -d` with health checks
+2. **Build all binaries** — `cargo build --release`
+3. **Start relay** — as a background process with stdout captured
+4. **Start harness** — with test keys, stdout captured to a log file
+5. **Send @mention events** — using the `mention` binary
+6. **Assert outcomes** — poll MySQL for expected reply events (with timeout)
+7. **Tear down** — kill harness, relay, `docker compose down`
+
+### Test Harness Sketch
+
+```bash
+#!/usr/bin/env bash
+# ci-integration-test.sh (aspirational)
+set -euo pipefail
+
+TIMEOUT=30
+RELAY_PID=""
+HARNESS_PID=""
+
+cleanup() {
+  [[ -n "$HARNESS_PID" ]] && kill "$HARNESS_PID" 2>/dev/null || true
+  [[ -n "$RELAY_PID" ]] && kill "$RELAY_PID" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+# Start relay
+just relay &
+RELAY_PID=$!
+sleep 3
+
+# Start harness
+SPROUT_ACP_PRIVATE_KEY=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5 \
+SPROUT_AGENT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
+SPROUT_ACP_AGENT_COMMAND=goose \
+SPROUT_ACP_AGENT_ARGS=acp \
+GOOSE_MODE=auto \
+./target/release/sprout-acp > /tmp/harness.log 2>&1 &
+HARNESS_PID=$!
+sleep 2
+
+# Send test mention
+./target/release/mention \
+  94a444a4-c0a3-5966-ab05-530c6ddc2301 \
+  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
+  "CI test: reply with OK"
+
+# Poll for reply
+START=$(date +%s)
+while true; do
+  REPLY=$(mysql -h 127.0.0.1 -u root -proot sprout -sNe \
+    "SELECT SUBSTRING(e.content, 1, 200) FROM events e
+     WHERE e.kind = 40001
+       AND HEX(e.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb'
+     ORDER BY e.created_at DESC LIMIT 1;" 2>/dev/null || true)
+  if [[ -n "$REPLY" ]]; then
+    echo "✅ Agent replied: $REPLY"
+    exit 0
+  fi
+  if (( $(date +%s) - START > TIMEOUT )); then
+    echo "❌ Timeout waiting for agent reply"
+    cat /tmp/harness.log
+    exit 1
+  fi
+  sleep 1
+done
+```
+
+### Blocking Issues for Full CI Automation
+
+- **Agent API keys** — goose, codex, and claude code all require live API keys; CI needs secrets management
+- **Relay startup time** — relay may need a longer warm-up than a fixed `sleep`; add a health-check poll
+- **Non-deterministic agent responses** — assertions should check for _any_ reply, not specific content
+- **Parallel test isolation** — each test run needs unique channel UUIDs to avoid cross-test contamination
+- **`mention` binary** — must be built as part of CI setup
+
+### Near-Term Automation Targets
+
+The highest-value scenarios to automate first (no live API keys needed if using a mock agent):
+
+1. **Scenario A** (basic flow) — core smoke test
+2. **Scenario C** (crash recovery) — critical reliability test
+3. **Scenario D** (relay reconnect) — critical reliability test
+4. **Scenario E** (turn timeout) — verifiable without agent intelligence
+
+Scenarios B, F, G, H can follow once the core pipeline is stable.

From 22547f7c792e95980a4ef81a99f68c18048194eb Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 21:39:28 -0400
Subject: [PATCH 3/8] =?UTF-8?q?docs:=20add=20sprout-acp=20README=20?=
 =?UTF-8?q?=E2=80=94=20how=20to=20run=20any=20agent=20in=20Sprout?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Concise getting-started guide covering:
- Quick start with goose (4 env vars + one command)
- Running with codex via codex-acp adapter
- Running with claude code via claude-agent-acp adapter
- Full configuration reference table
- How the harness works (lifecycle, recovery, batching)
- Using any ACP-compatible agent
---
 crates/sprout-acp/README.md | 119 ++++++++++++++++++++++++++++++++++++
 1 file changed, 119 insertions(+)
 create mode 100644 crates/sprout-acp/README.md

diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
new file mode 100644
index 0000000000..df593b0299
--- /dev/null
+++ b/crates/sprout-acp/README.md
@@ -0,0 +1,119 @@
+# sprout-acp
+
+ACP harness that connects AI agents to Sprout. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using Sprout MCP tools.
+
+```
+Sprout Relay ──WS──→ sprout-acp ──stdio──→ Your Agent
+                                               │
+                                          Sprout MCP
+                                       (send_message, etc.)
+```
+
+Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/zed-industries/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp)).
+
+## Prerequisites
+
+- A running Sprout relay (`just relay` or a hosted instance)
+- Docker services up (`docker compose up -d`) if running locally
+- Two Nostr keypairs: one for the harness (relay connection), one for the agent (identity)
+- The agent must be a member of at least one channel on the relay
+
+Build:
+
+```bash
+cargo build --release -p sprout-acp -p sprout-mcp-server
+export PATH="$PWD/target/release:$PATH"
+```
+
+## Quick Start (goose)
+
+```bash
+export SPROUT_ACP_PRIVATE_KEY="nsec1..."   # harness keypair
+export SPROUT_AGENT_PRIVATE_KEY="nsec1..."  # agent keypair
+export SPROUT_RELAY_URL="ws://localhost:3000"
+export GOOSE_MODE=auto
+
+sprout-acp
+```
+
+That's it. The harness spawns `goose acp`, connects to the relay, discovers the agent's channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically.
+
+## Running with Codex
+
+[codex-acp](https://github.com/zed-industries/codex-acp) wraps OpenAI Codex in an ACP interface.
+
+```bash
+# Build the adapter (requires Rust 1.91+)
+cd /path/to/codex-acp && cargo build --release
+
+# Run
+export OPENAI_API_KEY="sk-..."
+export SPROUT_ACP_AGENT_COMMAND="/path/to/codex-acp/target/release/codex-acp"
+export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
+
+sprout-acp
+```
+
+## Running with Claude Code
+
+[claude-agent-acp](https://github.com/zed-industries/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface.
+
+```bash
+# Build the adapter
+cd /path/to/claude-agent-acp && npm install && npm run build
+
+# Run
+export ANTHROPIC_API_KEY="sk-ant-..."
+export SPROUT_ACP_AGENT_COMMAND="node"
+export SPROUT_ACP_AGENT_ARGS="/path/to/claude-agent-acp/dist/index.js"
+
+sprout-acp
+```
+
+## Configuration
+
+All configuration is via environment variables.
+
+| Variable | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `SPROUT_ACP_PRIVATE_KEY` | **yes** | — | Harness Nostr private key (`nsec1...`). Used for relay auth. |
+| `SPROUT_AGENT_PRIVATE_KEY` | **yes** | — | Agent Nostr private key (`nsec1...`). The agent's identity in Sprout. |
+| `SPROUT_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. |
+| `SPROUT_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. |
+| `SPROUT_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). |
+| `SPROUT_ACP_MCP_COMMAND` | no | `sprout-mcp-server` | Path to the Sprout MCP server binary. |
+| `SPROUT_ACP_TURN_TIMEOUT` | no | `300` | Max seconds per agent turn before cancellation. |
+| `SPROUT_ACP_API_TOKEN` | no | — | Harness API token (required if relay enforces auth tokens). |
+| `SPROUT_AGENT_API_TOKEN` | no | — | Agent API token (passed to MCP server). |
+
+**Note:** `SPROUT_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`.
+
+## How It Works
+
+1. **Startup** — Spawns the agent subprocess, sends ACP `initialize`, connects to the relay with NIP-42 auth.
+2. **Channel discovery** — Queries the relay REST API for channels the agent is a member of, subscribes to each.
+3. **Event loop** — Listens for @mention events (kind 40001 with the agent's pubkey in a `#p` tag). Events queue per channel.
+4. **Prompting** — When events are pending and no prompt is in flight, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
+5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to interact with Sprout.
+6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.
+
+Only one prompt is in flight at a time (globally, not per-session). This matches the concurrency model of current ACP agents.
+
+## Using Any ACP Agent
+
+The harness works with any agent that implements the [ACP spec](https://agentclientprotocol.com/) over stdio. The requirements are:
+
+- Accept `initialize` and return a result
+- Accept `session/new` with `mcpServers` and return a `sessionId`
+- Accept `session/prompt` with a text message and stream `session/update` notifications
+- Return a `stopReason` (`end_turn`, `cancelled`, `max_tokens`, etc.)
+
+Set `SPROUT_ACP_AGENT_COMMAND` and `SPROUT_ACP_AGENT_ARGS` to point at your agent binary.
+
+## Testing
+
+See [TESTING.md](TESTING.md) for the full integration testing guide — 8 test scenarios, verification commands, troubleshooting, and CI integration notes.
+
+## License
+
+Apache-2.0

From a2920df4bbc94512722cf8e34b57af0e43d45264 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 21:51:43 -0400
Subject: [PATCH 4/8] docs: fix 6 doc gaps found by 3-agent live test

Gaps reported by a cold-start tester running goose, codex, and claude
simultaneously in the same channel:

README.md:
- Add 'Generating Keys' section (sprout-admin mint-token)
- Add 'Channel Membership' section (open channels vs explicit SQL)
- Clarify Quick Start uses test keys, link to key generation
- Add codex OPENAI_API_KEY note (avoid ChatGPT WebSocket fallback)

TESTING.md:
- Add Scenario I: Multi-Agent (3 agents, 1 channel) with full setup
- Add sprout-admin mint-token instructions to Test Keys section
- Add channel membership note (dev mode vs production)
---
 crates/sprout-acp/README.md  | 49 ++++++++++++++++---
 crates/sprout-acp/TESTING.md | 94 ++++++++++++++++++++++++++++++++++++
 2 files changed, 137 insertions(+), 6 deletions(-)

diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index df593b0299..6511526d2e 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -15,8 +15,8 @@ Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdi
 
 - A running Sprout relay (`just relay` or a hosted instance)
 - Docker services up (`docker compose up -d`) if running locally
-- Two Nostr keypairs: one for the harness (relay connection), one for the agent (identity)
-- The agent must be a member of at least one channel on the relay
+- Two Nostr keypairs: one for the harness, one for the agent (see [Generating Keys](#generating-keys))
+- The agent's keypair must be a member of at least one channel (see [Channel Membership](#channel-membership))
 
 Build:
 
@@ -25,18 +25,53 @@ cargo build --release -p sprout-acp -p sprout-mcp-server
 export PATH="$PWD/target/release:$PATH"
 ```
 
+## Generating Keys
+
+Each harness instance needs two Nostr keypairs — one for the harness (relay connection) and one for the agent (Sprout identity). Use `sprout-admin` to mint them:
+
+```bash
+# Mint a keypair + API token for the harness
+cargo run -p sprout-admin -- mint-token --name "my-harness" --scopes "channels:read"
+
+# Mint a keypair + API token for the agent
+cargo run -p sprout-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read"
+```
+
+Each command prints an `nsec1...` private key and an API token. **Save both immediately — they're shown only once.**
+
+Use the `nsec` values for `SPROUT_ACP_PRIVATE_KEY` (harness) and `SPROUT_AGENT_PRIVATE_KEY` (agent). The API tokens go in `SPROUT_ACP_API_TOKEN` and `SPROUT_AGENT_API_TOKEN` if the relay enforces token auth.
+
+> **Running multiple agents?** Mint a separate harness + agent keypair for each. Every agent needs its own identity.
+
+## Channel Membership
+
+The harness discovers channels by querying `GET /api/channels` with the harness's authenticated identity. The agent must be a member of at least one channel to receive @mentions.
+
+**Open channels (dev mode):** When the relay runs with `SPROUT_REQUIRE_AUTH_TOKEN=false` (the default for local dev), any pubkey can access open channels without explicit membership. The harness authenticates via NIP-42 and the relay returns all open channels.
+
+**Explicit membership (production):** When auth tokens are enforced, add the agent to a channel via SQL:
+
+```sql
+INSERT INTO channel_members (channel_id, pubkey, role)
+SELECT id, UNHEX('<AGENT_PUBKEY_HEX>'), 'bot'
+FROM channels WHERE name = '<channel-name>';
+```
+
+Replace `<AGENT_PUBKEY_HEX>` with the agent's 64-character hex public key (printed by `sprout-admin mint-token`).
+
 ## Quick Start (goose)
 
 ```bash
-export SPROUT_ACP_PRIVATE_KEY="nsec1..."   # harness keypair
-export SPROUT_AGENT_PRIVATE_KEY="nsec1..."  # agent keypair
+# Use your own keys (see "Generating Keys" above), or these test keys for local dev:
+export SPROUT_ACP_PRIVATE_KEY="nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5"
+export SPROUT_AGENT_PRIVATE_KEY="nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm"
 export SPROUT_RELAY_URL="ws://localhost:3000"
 export GOOSE_MODE=auto
 
 sprout-acp
 ```
 
-That's it. The harness spawns `goose acp`, connects to the relay, discovers the agent's channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically.
+The harness spawns `goose acp`, connects to the relay, discovers the agent's channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically.
 
 ## Running with Codex
 
@@ -47,13 +82,15 @@ That's it. The harness spawns `goose acp`, connects to the relay, discovers the
 cd /path/to/codex-acp && cargo build --release
 
 # Run
-export OPENAI_API_KEY="sk-..."
+export OPENAI_API_KEY="sk-..."   # required — use an OpenAI API key, not a ChatGPT subscription
 export SPROUT_ACP_AGENT_COMMAND="/path/to/codex-acp/target/release/codex-acp"
 export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
 
 sprout-acp
 ```
 
+> **API key note:** `codex-acp` checks multiple auth methods. Set `OPENAI_API_KEY` explicitly — without it, the adapter may attempt a ChatGPT WebSocket login that fails in headless mode.
+
 ## Running with Claude Code
 
 [claude-agent-acp](https://github.com/zed-industries/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface.
diff --git a/crates/sprout-acp/TESTING.md b/crates/sprout-acp/TESTING.md
index 8ff3b78fb7..dc81db61c1 100644
--- a/crates/sprout-acp/TESTING.md
+++ b/crates/sprout-acp/TESTING.md
@@ -85,6 +85,14 @@ export AGENT_PUBKEY_HEX=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0
 export AGENTS_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301
 ```
 
+**Generating additional keypairs:** Use `sprout-admin mint-token` to create new keypairs for additional agents or harness instances. Each call prints an `nsec` private key and API token — save them immediately.
+
+```bash
+cargo run -p sprout-admin -- mint-token --name "agent-2" --scopes "messages:read,messages:write,channels:read"
+```
+
+**Channel membership:** In local dev mode (`SPROUT_REQUIRE_AUTH_TOKEN=false`), any pubkey can access open channels without explicit membership. For production, add agents to channels via SQL (see the [README](README.md#channel-membership)).
+
 ### 5. MySQL Access
 
 ```bash
@@ -539,6 +547,92 @@ screen -r harness
 
 ---
 
+### Scenario I: Multi-Agent (3 Agents, 1 Channel)
+
+**Description:** Run three harness instances (goose, codex, claude) simultaneously, all subscribed to the same channel. Send an @mention to one; verify all three see the event and respond.
+
+**Setup:** Each agent needs its own keypair pair (harness + agent). Mint them with `sprout-admin`:
+
+```bash
+# Agent 1 (goose) — use the test keys from Prerequisites, or mint fresh ones
+# Agent 2 (codex)
+cargo run -p sprout-admin -- mint-token --name "harness-codex" --scopes "channels:read"
+cargo run -p sprout-admin -- mint-token --name "agent-codex" --scopes "messages:read,messages:write,channels:read"
+
+# Agent 3 (claude)
+cargo run -p sprout-admin -- mint-token --name "harness-claude" --scopes "channels:read"
+cargo run -p sprout-admin -- mint-token --name "agent-claude" --scopes "messages:read,messages:write,channels:read"
+```
+
+Save all `nsec` keys and pubkey hex values from the output.
+
+**Steps:**
+
+1. Start three harness instances in separate screen sessions:
+
+```bash
+# Goose
+SPROUT_ACP_PRIVATE_KEY=<harness-goose-nsec> \
+SPROUT_AGENT_PRIVATE_KEY=<agent-goose-nsec> \
+GOOSE_MODE=auto \
+screen -dmS harness-goose sprout-acp
+
+# Codex
+OPENAI_API_KEY=$(cat ~/keys/openai.key) \
+SPROUT_ACP_PRIVATE_KEY=<harness-codex-nsec> \
+SPROUT_AGENT_PRIVATE_KEY=<agent-codex-nsec> \
+SPROUT_ACP_AGENT_COMMAND=/path/to/codex-acp/target/release/codex-acp \
+SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"' \
+screen -dmS harness-codex sprout-acp
+
+# Claude
+ANTHROPIC_API_KEY=$(cat ~/keys/anthropic.key) \
+SPROUT_ACP_PRIVATE_KEY=<harness-claude-nsec> \
+SPROUT_AGENT_PRIVATE_KEY=<agent-claude-nsec> \
+SPROUT_ACP_AGENT_COMMAND=node \
+SPROUT_ACP_AGENT_ARGS=/path/to/claude-agent-acp/dist/index.js \
+screen -dmS harness-claude sprout-acp
+```
+
+2. Verify all three are subscribed:
+
+```bash
+for s in harness-goose harness-codex harness-claude; do
+  echo "=== $s ==="; screen -S $s -X hardcopy /tmp/$s.txt; grep -c "subscribed" /tmp/$s.txt
+done
+```
+
+3. Send @mentions to each agent:
+
+```bash
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 <goose-agent-pubkey-hex> "Hello goose, reply PONG"
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 <codex-agent-pubkey-hex> "Hello codex, reply PONG"
+mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 <claude-agent-pubkey-hex> "Hello claude, reply PONG"
+```
+
+**Expected behavior:**
+- Each harness picks up only the @mention targeting its agent pubkey
+- Each agent replies via `send_message`
+- All three replies appear in the DB with different sender pubkeys
+
+**Verify:**
+
+```bash
+docker exec sprout-mysql mysql -u sprout -psprout_dev sprout -e "
+SELECT SUBSTRING(HEX(pubkey), 1, 16) as sender, SUBSTRING(content, 1, 100) as body, created_at
+FROM events WHERE kind = 40001 ORDER BY created_at DESC LIMIT 10;"
+```
+
+Look for three distinct sender pubkey prefixes, each with a PONG reply.
+
+**Cleanup:**
+
+```bash
+for s in harness-goose harness-codex harness-claude; do screen -S $s -X quit; done
+```
+
+---
+
 ## Verification Commands
 
 ### Harness Log Patterns

From b43764ed17eec92e12087d48dd1e1a7ea4861086 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 21:56:37 -0400
Subject: [PATCH 5/8] fix: cargo fmt + clippy warnings for CI

- Run cargo fmt --all (line length, argument grouping)
- Box RelayError::WebSocket variant to fix clippy::result_large_err
- Add #[allow(dead_code)] for public API methods not yet called from main
- Replace or_insert_with(VecDeque::new) with or_default()
---
 crates/sprout-acp/src/acp.rs                 | 20 ++----
 crates/sprout-acp/src/config.rs              |  8 +--
 crates/sprout-acp/src/main.rs                | 18 ++----
 crates/sprout-acp/src/queue.rs               |  8 +--
 crates/sprout-acp/src/relay.rs               | 67 ++++++++++++++------
 crates/sprout-test-client/src/bin/mention.rs |  4 +-
 6 files changed, 68 insertions(+), 57 deletions(-)

diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs
index 20cf972ebd..049d199534 100644
--- a/crates/sprout-acp/src/acp.rs
+++ b/crates/sprout-acp/src/acp.rs
@@ -227,10 +227,7 @@ impl AcpClient {
     /// 3. Continue reading until the `session/prompt` response arrives with `stopReason: "cancelled"`.
     ///
     /// Returns the final [`StopReason`] (almost always [`StopReason::Cancelled`]).
-    pub async fn cancel_with_cleanup(
-        &mut self,
-        session_id: &str,
-    ) -> Result<StopReason, AcpError> {
+    pub async fn cancel_with_cleanup(&mut self, session_id: &str) -> Result<StopReason, AcpError> {
         // Step 1: respond to any pending permission request with "cancelled",
         // but only if we haven't already responded (guards against double-response race).
         if let Some(perm_id) = self.pending_permission_id.clone() {
@@ -414,10 +411,7 @@ impl AcpClient {
                     .get("toolCallId")
                     .and_then(|v| v.as_str())
                     .unwrap_or("?");
-                let status = update
-                    .get("status")
-                    .and_then(|v| v.as_str())
-                    .unwrap_or("?");
+                let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("?");
                 tracing::info!(target: "acp::tool", "tool_call_update: {tool_id} → {status}");
             }
             "plan" => {
@@ -443,10 +437,7 @@ impl AcpClient {
     ///
     /// The request `id` is stored as `serde_json::Value` to support both numeric
     /// and string IDs per JSON-RPC 2.0.
-    async fn handle_permission_request(
-        &mut self,
-        msg: &serde_json::Value,
-    ) -> Result<(), AcpError> {
+    async fn handle_permission_request(&mut self, msg: &serde_json::Value) -> Result<(), AcpError> {
         // Extract id as a Value — JSON-RPC 2.0 allows both numeric and string IDs.
         let id = msg
             .get("id")
@@ -815,10 +806,7 @@ mod tests {
         // Must have no id (notification)
         assert!(msg.get("id").is_none());
         // Must have sessionId in params
-        assert_eq!(
-            msg["params"]["sessionId"].as_str(),
-            Some("sess_xyz789")
-        );
+        assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess_xyz789"));
     }
 
     // ── String ID handling (Fix 1) ────────────────────────────────────────
diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs
index c738486684..aac6d78c1a 100644
--- a/crates/sprout-acp/src/config.rs
+++ b/crates/sprout-acp/src/config.rs
@@ -60,15 +60,15 @@ impl Config {
 
         let harness_api_token = std::env::var("SPROUT_ACP_API_TOKEN").ok();
 
-        let relay_url = std::env::var("SPROUT_RELAY_URL")
-            .unwrap_or_else(|_| "ws://localhost:3000".to_string());
+        let relay_url =
+            std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
 
         let agent_keys = parse_keys_var("SPROUT_AGENT_PRIVATE_KEY")?;
 
         let agent_api_token = std::env::var("SPROUT_AGENT_API_TOKEN").ok();
 
-        let agent_command = std::env::var("SPROUT_ACP_AGENT_COMMAND")
-            .unwrap_or_else(|_| "goose".to_string());
+        let agent_command =
+            std::env::var("SPROUT_ACP_AGENT_COMMAND").unwrap_or_else(|_| "goose".to_string());
 
         let agent_args = std::env::var("SPROUT_ACP_AGENT_ARGS")
             .map(|s| s.split(',').map(|a| a.trim().to_string()).collect())
diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs
index 9062a235a5..822fea66c6 100644
--- a/crates/sprout-acp/src/main.rs
+++ b/crates/sprout-acp/src/main.rs
@@ -21,8 +21,7 @@ use relay::HarnessRelay;
 async fn main() -> Result<()> {
     tracing_subscriber::fmt()
         .with_env_filter(
-            EnvFilter::try_from_default_env()
-                .unwrap_or_else(|_| EnvFilter::new("sprout_acp=info")),
+            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("sprout_acp=info")),
         )
         .compact()
         .init();
@@ -126,11 +125,8 @@ async fn main() -> Result<()> {
                     );
 
                     // Send prompt with turn timeout.
-                    let prompt_result = timeout(
-                        turn_timeout,
-                        acp.session_prompt(&session_id, &prompt_text),
-                    )
-                    .await;
+                    let prompt_result =
+                        timeout(turn_timeout, acp.session_prompt(&session_id, &prompt_text)).await;
 
                     match prompt_result {
                         Ok(Ok(stop_reason)) => {
@@ -149,9 +145,7 @@ async fn main() -> Result<()> {
                             break;
                         }
                         Ok(Err(e)) => {
-                            tracing::error!(
-                                "session_prompt error for channel {channel_id}: {e}"
-                            );
+                            tracing::error!("session_prompt error for channel {channel_id}: {e}");
                             // Re-queue so events aren't lost on transient prompt error.
                             // Break (not continue) to avoid hot-loop on persistent failure.
                             queue.requeue(batch);
@@ -172,9 +166,7 @@ async fn main() -> Result<()> {
                                     log_stop_reason(channel_id, &stop_reason);
                                 }
                                 Err(AcpError::AgentExited) => {
-                                    tracing::error!(
-                                        "agent exited during cancel — respawning"
-                                    );
+                                    tracing::error!("agent exited during cancel — respawning");
                                     sessions.clear();
                                     acp = respawn_agent(&config).await?;
                                 }
diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs
index 3bf337a71f..c97e06ff8a 100644
--- a/crates/sprout-acp/src/queue.rs
+++ b/crates/sprout-acp/src/queue.rs
@@ -115,10 +115,7 @@ impl EventQueue {
     /// This means a re-queued channel competes fairly with other channels rather
     /// than always winning due to stale timestamps.
     pub fn requeue(&mut self, batch: FlushBatch) {
-        let queue = self
-            .queues
-            .entry(batch.channel_id)
-            .or_insert_with(VecDeque::new);
+        let queue = self.queues.entry(batch.channel_id).or_default();
         // Push to front in reverse order so original order is preserved.
         for event in batch.events.into_iter().rev() {
             queue.push_front(QueuedEvent {
@@ -130,16 +127,19 @@ impl EventQueue {
     }
 
     /// Whether a prompt is currently in flight.
+    #[allow(dead_code)]
     pub fn is_in_flight(&self) -> bool {
         self.prompt_in_flight
     }
 
     /// Total number of pending events across all channels.
+    #[allow(dead_code)]
     pub fn pending_count(&self) -> usize {
         self.queues.values().map(|q| q.len()).sum()
     }
 
     /// Number of channels with pending events.
+    #[allow(dead_code)]
     pub fn pending_channels(&self) -> usize {
         self.queues.len()
     }
diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs
index 66a485718e..9dae5964fd 100644
--- a/crates/sprout-acp/src/relay.rs
+++ b/crates/sprout-acp/src/relay.rs
@@ -63,7 +63,7 @@ pub struct SproutEvent {
 #[derive(Debug, thiserror::Error)]
 pub enum RelayError {
     #[error("WebSocket error: {0}")]
-    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
+    WebSocket(Box<tokio_tungstenite::tungstenite::Error>),
 
     #[error("JSON error: {0}")]
     Json(#[from] serde_json::Error),
@@ -129,6 +129,7 @@ enum RelayCommand {
     /// Subscribe to a channel (sends a NIP-01 REQ).
     Subscribe { channel_id: Uuid },
     /// Unsubscribe from a channel (sends a NIP-01 CLOSE).
+    #[allow(dead_code)]
     Unsubscribe { channel_id: Uuid },
     /// Reconnect to the relay (re-authenticate and resubscribe).
     Reconnect,
@@ -163,6 +164,7 @@ pub struct HarnessRelay {
     /// Keys used for NIP-42 signing.
     keys: Keys,
     /// Agent public key (hex) used as the `#p` filter on subscriptions.
+    #[allow(dead_code)]
     agent_pubkey_hex: String,
     /// Handle to the background task (for clean shutdown).
     bg_handle: tokio::task::JoinHandle<()>,
@@ -274,6 +276,7 @@ impl HarnessRelay {
     }
 
     /// Unsubscribe from a channel.
+    #[allow(dead_code)]
     pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> {
         self.cmd_tx
             .send(RelayCommand::Unsubscribe { channel_id })
@@ -624,7 +627,10 @@ async fn wait_for_reconnect(
                 return;
             }
             Err(e) => {
-                warn!("relay reconnect failed: {e} — retrying in {}s", delay.as_secs());
+                warn!(
+                    "relay reconnect failed: {e} — retrying in {}s",
+                    delay.as_secs()
+                );
                 tokio::time::sleep(delay).await;
                 delay = (delay * 2).min(Duration::from_secs(60));
             }
@@ -664,14 +670,20 @@ async fn send_subscribe(
             if let Err(e) = ws.send(Message::Text(text.into())).await {
                 warn!("failed to send REQ for channel {channel_id}: {e}");
             } else {
-                debug!("subscribed to channel {channel_id}{}", if since.is_some() { " (with since filter)" } else { "" });
+                debug!(
+                    "subscribed to channel {channel_id}{}",
+                    if since.is_some() {
+                        " (with since filter)"
+                    } else {
+                        ""
+                    }
+                );
             }
         }
         Err(e) => {
             warn!("failed to serialize REQ for channel {channel_id}: {e}");
         }
     }
-
 }
 
 /// Build and send a NIP-42 AUTH response event.
@@ -688,8 +700,7 @@ async fn send_auth_response(
 
     let auth_event = if let Some(token) = api_token {
         let tags = vec![
-            Tag::parse(&["relay", relay_url])
-                .map_err(|e| RelayError::AuthFailed(e.to_string()))?,
+            Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?,
             Tag::parse(&["challenge", challenge])
                 .map_err(|e| RelayError::AuthFailed(e.to_string()))?,
             Tag::parse(&["auth_token", token])
@@ -701,7 +712,9 @@ async fn send_auth_response(
     };
 
     let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
-    ws.send(Message::Text(auth_msg.into())).await?;
+    ws.send(Message::Text(auth_msg.into()))
+        .await
+        .map_err(|e| RelayError::WebSocket(Box::new(e)))?;
     debug!("sent AUTH response for challenge");
     Ok(())
 }
@@ -853,15 +866,16 @@ async fn do_connect(
         .parse::<url::Url>()
         .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
 
-    let (ws, _response) = connect_async(parsed.as_str()).await?;
+    let (ws, _response) = connect_async(parsed.as_str())
+        .await
+        .map_err(|e| RelayError::WebSocket(Box::new(e)))?;
     debug!("connected to relay at {relay_url}");
 
     let mut ws = ws;
     let mut buffer: VecDeque<RelayMessage> = VecDeque::new();
 
     // ── Step 1: Wait for AUTH challenge ───────────────────────────────────
-    let challenge =
-        wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
+    let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
 
     // ── Step 2: Build and send kind:22242 auth event ──────────────────────
     send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?;
@@ -915,7 +929,7 @@ async fn wait_for_auth_challenge(
             .await
             .map_err(|_| RelayError::NoAuthChallenge)?
             .ok_or(RelayError::ConnectionClosed)?
-            .map_err(RelayError::WebSocket)?;
+            .map_err(|e| RelayError::WebSocket(Box::new(e)))?;
 
         match raw {
             Message::Text(text) => {
@@ -926,7 +940,9 @@ async fn wait_for_auth_challenge(
                 }
             }
             Message::Ping(data) => {
-                ws.send(Message::Pong(data)).await?;
+                ws.send(Message::Pong(data))
+                    .await
+                    .map_err(|e| RelayError::WebSocket(Box::new(e)))?;
             }
             Message::Close(_) => return Err(RelayError::ConnectionClosed),
             _ => {}
@@ -981,7 +997,7 @@ async fn wait_for_any_ok(
             .await
             .map_err(|_| RelayError::Timeout)?
             .ok_or(RelayError::ConnectionClosed)?
-            .map_err(RelayError::WebSocket)?;
+            .map_err(|e| RelayError::WebSocket(Box::new(e)))?;
 
         match raw {
             Message::Text(text) => {
@@ -1002,7 +1018,9 @@ async fn wait_for_any_ok(
                 }
             }
             Message::Ping(data) => {
-                ws.send(Message::Pong(data)).await?;
+                ws.send(Message::Pong(data))
+                    .await
+                    .map_err(|e| RelayError::WebSocket(Box::new(e)))?;
             }
             Message::Close(_) => return Err(RelayError::ConnectionClosed),
             _ => {}
@@ -1293,7 +1311,10 @@ mod tests {
         let channel_id = Uuid::new_v4();
         let keys = nostr::Keys::generate();
         let event = make_test_event(&keys, 1_000_000);
-        assert!(state.record_event(channel_id, &event), "first event should be accepted");
+        assert!(
+            state.record_event(channel_id, &event),
+            "first event should be accepted"
+        );
     }
 
     #[test]
@@ -1302,8 +1323,14 @@ mod tests {
         let channel_id = Uuid::new_v4();
         let keys = nostr::Keys::generate();
         let event = make_test_event(&keys, 1_000_000);
-        assert!(state.record_event(channel_id, &event), "first should be accepted");
-        assert!(!state.record_event(channel_id, &event), "duplicate should be rejected");
+        assert!(
+            state.record_event(channel_id, &event),
+            "first should be accepted"
+        );
+        assert!(
+            !state.record_event(channel_id, &event),
+            "duplicate should be rejected"
+        );
     }
 
     #[test]
@@ -1394,7 +1421,11 @@ mod tests {
 
         // First insert: 12_000 entries — no clear triggered yet.
         state.record_event(channel_id, &event1);
-        assert_eq!(state.seen_ids.len(), 12_000, "should be at 12_000 before clear");
+        assert_eq!(
+            state.seen_ids.len(),
+            12_000,
+            "should be at 12_000 before clear"
+        );
 
         // Second insert: 12_001 entries — triggers clear, then re-inserts.
         state.record_event(channel_id, &event2);
diff --git a/crates/sprout-test-client/src/bin/mention.rs b/crates/sprout-test-client/src/bin/mention.rs
index a3516ace90..69e52791e5 100644
--- a/crates/sprout-test-client/src/bin/mention.rs
+++ b/crates/sprout-test-client/src/bin/mention.rs
@@ -23,8 +23,8 @@ async fn main() -> anyhow::Result<()> {
 
     let e_tag = Tag::parse(&["e", channel_id])?;
     let p_tag = Tag::parse(&["p", target_pubkey])?;
-    let event = EventBuilder::new(Kind::Custom(40001), message, [e_tag, p_tag])
-        .sign_with_keys(&keys)?;
+    let event =
+        EventBuilder::new(Kind::Custom(40001), message, [e_tag, p_tag]).sign_with_keys(&keys)?;
 
     let ok = client.send_event(event).await?;
     if ok.accepted {

From 5e416e2dfb7cc141cd9de1c6cc4c77c3961e0c5b Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 22:02:12 -0400
Subject: [PATCH 6/8] simplify: collapse to single keypair, fix channel docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The harness and agent share one identity — there was never a reason
for separate keypairs. This simplifies configuration from 2 required
env vars to 1:

  SPROUT_PRIVATE_KEY=nsec1...  # that's it

Code changes:
- config.rs: Single 'keys' field, reads SPROUT_PRIVATE_KEY (falls back
  to legacy SPROUT_ACP_PRIVATE_KEY for compat)
- main.rs: Use config.keys everywhere (was config.harness_keys +
  config.agent_keys)

Doc changes:
- README: Rewrite to single key, replace SQL membership instructions
  with explanation that open channels just work, note private channel
  membership is a relay API gap
- TESTING: Update all env var references, simplify multi-agent setup
---
 crates/sprout-acp/README.md     | 49 +++++++++++----------------------
 crates/sprout-acp/TESTING.md    | 43 ++++++++++-------------------
 crates/sprout-acp/src/config.rs | 44 ++++++++++++++---------------
 crates/sprout-acp/src/main.rs   | 30 ++++----------------
 4 files changed, 57 insertions(+), 109 deletions(-)

diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index 6511526d2e..3e6bac0cee 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -15,8 +15,7 @@ Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdi
 
 - A running Sprout relay (`just relay` or a hosted instance)
 - Docker services up (`docker compose up -d`) if running locally
-- Two Nostr keypairs: one for the harness, one for the agent (see [Generating Keys](#generating-keys))
-- The agent's keypair must be a member of at least one channel (see [Channel Membership](#channel-membership))
+- A Nostr keypair for the agent (see [Generating Keys](#generating-keys))
 
 Build:
 
@@ -27,51 +26,35 @@ export PATH="$PWD/target/release:$PATH"
 
 ## Generating Keys
 
-Each harness instance needs two Nostr keypairs — one for the harness (relay connection) and one for the agent (Sprout identity). Use `sprout-admin` to mint them:
+Each agent needs a Nostr keypair — this is the agent's identity in Sprout. Use `sprout-admin` to mint one:
 
 ```bash
-# Mint a keypair + API token for the harness
-cargo run -p sprout-admin -- mint-token --name "my-harness" --scopes "channels:read"
-
-# Mint a keypair + API token for the agent
 cargo run -p sprout-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read"
 ```
 
-Each command prints an `nsec1...` private key and an API token. **Save both immediately — they're shown only once.**
-
-Use the `nsec` values for `SPROUT_ACP_PRIVATE_KEY` (harness) and `SPROUT_AGENT_PRIVATE_KEY` (agent). The API tokens go in `SPROUT_ACP_API_TOKEN` and `SPROUT_AGENT_API_TOKEN` if the relay enforces token auth.
-
-> **Running multiple agents?** Mint a separate harness + agent keypair for each. Every agent needs its own identity.
+This prints an `nsec1...` private key and an API token. **Save both immediately — they're shown only once.**
 
-## Channel Membership
+> **Running multiple agents?** Mint a separate keypair for each. Every agent needs its own identity.
 
-The harness discovers channels by querying `GET /api/channels` with the harness's authenticated identity. The agent must be a member of at least one channel to receive @mentions.
+## Channels
 
-**Open channels (dev mode):** When the relay runs with `SPROUT_REQUIRE_AUTH_TOKEN=false` (the default for local dev), any pubkey can access open channels without explicit membership. The harness authenticates via NIP-42 and the relay returns all open channels.
+The harness discovers channels by querying the relay with the agent's authenticated identity.
 
-**Explicit membership (production):** When auth tokens are enforced, add the agent to a channel via SQL:
+**Open channels** (the default for local dev) are accessible to any authenticated pubkey — no extra setup needed. Just start the harness and it will find and subscribe to all open channels.
 
-```sql
-INSERT INTO channel_members (channel_id, pubkey, role)
-SELECT id, UNHEX('<AGENT_PUBKEY_HEX>'), 'bot'
-FROM channels WHERE name = '<channel-name>';
-```
-
-Replace `<AGENT_PUBKEY_HEX>` with the agent's 64-character hex public key (printed by `sprout-admin mint-token`).
+**Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Sprout MCP tools to create new channels (the creator is automatically a member).
 
 ## Quick Start (goose)
 
 ```bash
-# Use your own keys (see "Generating Keys" above), or these test keys for local dev:
-export SPROUT_ACP_PRIVATE_KEY="nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5"
-export SPROUT_AGENT_PRIVATE_KEY="nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm"
+export SPROUT_PRIVATE_KEY="nsec1..."   # your agent's key (see "Generating Keys")
 export SPROUT_RELAY_URL="ws://localhost:3000"
 export GOOSE_MODE=auto
 
 sprout-acp
 ```
 
-The harness spawns `goose acp`, connects to the relay, discovers the agent's channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically.
+That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Sprout MCP tools that the harness configures automatically.
 
 ## Running with Codex
 
@@ -113,22 +96,22 @@ All configuration is via environment variables.
 
 | Variable | Required | Default | Description |
 |----------|----------|---------|-------------|
-| `SPROUT_ACP_PRIVATE_KEY` | **yes** | — | Harness Nostr private key (`nsec1...`). Used for relay auth. |
-| `SPROUT_AGENT_PRIVATE_KEY` | **yes** | — | Agent Nostr private key (`nsec1...`). The agent's identity in Sprout. |
+| `SPROUT_PRIVATE_KEY` | **yes** | — | Agent's Nostr private key (`nsec1...`). Used for relay auth and agent identity. |
 | `SPROUT_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. |
 | `SPROUT_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. |
 | `SPROUT_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). |
 | `SPROUT_ACP_MCP_COMMAND` | no | `sprout-mcp-server` | Path to the Sprout MCP server binary. |
 | `SPROUT_ACP_TURN_TIMEOUT` | no | `300` | Max seconds per agent turn before cancellation. |
-| `SPROUT_ACP_API_TOKEN` | no | — | Harness API token (required if relay enforces auth tokens). |
-| `SPROUT_AGENT_API_TOKEN` | no | — | Agent API token (passed to MCP server). |
+| `SPROUT_API_TOKEN` | no | — | API token (required if relay enforces token auth). |
 
 **Note:** `SPROUT_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`.
 
+**Legacy env vars:** `SPROUT_ACP_PRIVATE_KEY` and `SPROUT_ACP_API_TOKEN` are still accepted as fallbacks.
+
 ## How It Works
 
 1. **Startup** — Spawns the agent subprocess, sends ACP `initialize`, connects to the relay with NIP-42 auth.
-2. **Channel discovery** — Queries the relay REST API for channels the agent is a member of, subscribes to each.
+2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
 3. **Event loop** — Listens for @mention events (kind 40001 with the agent's pubkey in a `#p` tag). Events queue per channel.
 4. **Prompting** — When events are pending and no prompt is in flight, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
 5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to interact with Sprout.
@@ -149,7 +132,7 @@ Set `SPROUT_ACP_AGENT_COMMAND` and `SPROUT_ACP_AGENT_ARGS` to point at your agen
 
 ## Testing
 
-See [TESTING.md](TESTING.md) for the full integration testing guide — 8 test scenarios, verification commands, troubleshooting, and CI integration notes.
+See [TESTING.md](TESTING.md) for the full integration testing guide — 9 test scenarios, verification commands, troubleshooting, and CI integration notes.
 
 ## License
 
diff --git a/crates/sprout-acp/TESTING.md b/crates/sprout-acp/TESTING.md
index dc81db61c1..914f6c4996 100644
--- a/crates/sprout-acp/TESTING.md
+++ b/crates/sprout-acp/TESTING.md
@@ -79,19 +79,18 @@ export PATH="$PWD/target/release:$PATH"
 These keys are for local development only — never use in production.
 
 ```bash
-export HARNESS_NSEC=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5
-export AGENT_NSEC=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
+export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
 export AGENT_PUBKEY_HEX=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb
 export AGENTS_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301
 ```
 
-**Generating additional keypairs:** Use `sprout-admin mint-token` to create new keypairs for additional agents or harness instances. Each call prints an `nsec` private key and API token — save them immediately.
+**Generating additional keypairs:** Use `sprout-admin mint-token` to create new keypairs for additional agents. Each call prints an `nsec` private key and API token — save them immediately.
 
 ```bash
-cargo run -p sprout-admin -- mint-token --name "agent-2" --scopes "messages:read,messages:write,channels:read"
+cargo run -p sprout-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read"
 ```
 
-**Channel membership:** In local dev mode (`SPROUT_REQUIRE_AUTH_TOKEN=false`), any pubkey can access open channels without explicit membership. For production, add agents to channels via SQL (see the [README](README.md#channel-membership)).
+**Channels:** Open channels (the default for local dev) are accessible to any authenticated pubkey — no extra setup needed. See the [README](README.md#channels) for details.
 
 ### 5. MySQL Access
 
@@ -110,11 +109,8 @@ Minimal path: run harness with goose, send a test `@mention`, verify reply.
 
 ```bash
 export PATH="$PWD/target/release:$PATH"
-export SPROUT_ACP_PRIVATE_KEY=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5
-export SPROUT_AGENT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
+export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
 export SPROUT_RELAY_URL=ws://localhost:3000
-export SPROUT_ACP_AGENT_COMMAND=goose
-export SPROUT_ACP_AGENT_ARGS=acp
 export GOOSE_MODE=auto
 ```
 
@@ -412,8 +408,7 @@ screen -r harness
 ```bash
 screen -S harness -X quit
 SPROUT_ACP_TURN_TIMEOUT=5 \
-SPROUT_ACP_PRIVATE_KEY=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5 \
-SPROUT_AGENT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
+SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
 SPROUT_ACP_AGENT_COMMAND=goose \
 SPROUT_ACP_AGENT_ARGS=acp \
 GOOSE_MODE=auto \
@@ -551,20 +546,18 @@ screen -r harness
 
 **Description:** Run three harness instances (goose, codex, claude) simultaneously, all subscribed to the same channel. Send an @mention to one; verify all three see the event and respond.
 
-**Setup:** Each agent needs its own keypair pair (harness + agent). Mint them with `sprout-admin`:
+**Setup:** Each agent needs its own keypair. Mint them with `sprout-admin`:
 
 ```bash
-# Agent 1 (goose) — use the test keys from Prerequisites, or mint fresh ones
+# Agent 1 (goose) — use the test key from Prerequisites, or mint a fresh one
 # Agent 2 (codex)
-cargo run -p sprout-admin -- mint-token --name "harness-codex" --scopes "channels:read"
 cargo run -p sprout-admin -- mint-token --name "agent-codex" --scopes "messages:read,messages:write,channels:read"
 
 # Agent 3 (claude)
-cargo run -p sprout-admin -- mint-token --name "harness-claude" --scopes "channels:read"
 cargo run -p sprout-admin -- mint-token --name "agent-claude" --scopes "messages:read,messages:write,channels:read"
 ```
 
-Save all `nsec` keys and pubkey hex values from the output.
+Save the `nsec` keys and pubkey hex values from the output.
 
 **Steps:**
 
@@ -572,23 +565,20 @@ Save all `nsec` keys and pubkey hex values from the output.
 
 ```bash
 # Goose
-SPROUT_ACP_PRIVATE_KEY=<harness-goose-nsec> \
-SPROUT_AGENT_PRIVATE_KEY=<agent-goose-nsec> \
+SPROUT_PRIVATE_KEY=<goose-nsec> \
 GOOSE_MODE=auto \
 screen -dmS harness-goose sprout-acp
 
 # Codex
 OPENAI_API_KEY=$(cat ~/keys/openai.key) \
-SPROUT_ACP_PRIVATE_KEY=<harness-codex-nsec> \
-SPROUT_AGENT_PRIVATE_KEY=<agent-codex-nsec> \
+SPROUT_PRIVATE_KEY=<codex-nsec> \
 SPROUT_ACP_AGENT_COMMAND=/path/to/codex-acp/target/release/codex-acp \
 SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"' \
 screen -dmS harness-codex sprout-acp
 
 # Claude
 ANTHROPIC_API_KEY=$(cat ~/keys/anthropic.key) \
-SPROUT_ACP_PRIVATE_KEY=<harness-claude-nsec> \
-SPROUT_AGENT_PRIVATE_KEY=<agent-claude-nsec> \
+SPROUT_PRIVATE_KEY=<claude-nsec> \
 SPROUT_ACP_AGENT_COMMAND=node \
 SPROUT_ACP_AGENT_ARGS=/path/to/claude-agent-acp/dist/index.js \
 screen -dmS harness-claude sprout-acp
@@ -729,11 +719,10 @@ screen -ls
 
 **Cause:** Missing required env vars.
 
-**Fix:** Ensure both `SPROUT_ACP_PRIVATE_KEY` and `SPROUT_AGENT_PRIVATE_KEY` are set:
+**Fix:** Ensure `SPROUT_PRIVATE_KEY` is set:
 
 ```bash
-echo $SPROUT_ACP_PRIVATE_KEY
-echo $SPROUT_AGENT_PRIVATE_KEY
+echo $SPROUT_PRIVATE_KEY
 ```
 
 Both must be valid `nsec1...` bech32 strings.
@@ -942,9 +931,7 @@ RELAY_PID=$!
 sleep 3
 
 # Start harness
-SPROUT_ACP_PRIVATE_KEY=nsec14xmptmamx2x3adrsrca8dng42xhdh9afgvsjajtutyr5dcw4pxcqr5ccq5 \
-SPROUT_AGENT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
-SPROUT_ACP_AGENT_COMMAND=goose \
+SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
 SPROUT_ACP_AGENT_ARGS=acp \
 GOOSE_MODE=auto \
 ./target/release/sprout-acp > /tmp/harness.log 2>&1 &
diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs
index aac6d78c1a..8933df0613 100644
--- a/crates/sprout-acp/src/config.rs
+++ b/crates/sprout-acp/src/config.rs
@@ -18,20 +18,16 @@ pub enum ConfigError {
 /// Configuration for the sprout-acp harness.
 #[derive(Debug)]
 pub struct Config {
-    // --- Harness identity (relay WebSocket connection) ---
-    /// Harness's nostr keypair, parsed from `SPROUT_ACP_PRIVATE_KEY`.
-    pub harness_keys: Keys,
-    /// Harness's API token (`SPROUT_ACP_API_TOKEN`), optional.
-    pub harness_api_token: Option<String>,
+    /// Agent's nostr keypair — used for both relay auth and agent identity.
+    ///
+    /// Parsed from `SPROUT_PRIVATE_KEY` (preferred) or the legacy
+    /// `SPROUT_ACP_PRIVATE_KEY` + `SPROUT_AGENT_PRIVATE_KEY` pair.
+    pub keys: Keys,
+    /// API token, optional. Required if the relay enforces token auth.
+    pub api_token: Option<String>,
     /// Relay WebSocket URL (`SPROUT_RELAY_URL`). Default: `ws://localhost:3000`.
     pub relay_url: String,
 
-    // --- Agent identity (passed to MCP server via env vars) ---
-    /// Agent's nostr keypair, parsed from `SPROUT_AGENT_PRIVATE_KEY`.
-    pub agent_keys: Keys,
-    /// Agent's API token (`SPROUT_AGENT_API_TOKEN`), optional.
-    pub agent_api_token: Option<String>,
-
     // --- Agent binary ---
     /// Agent command (`SPROUT_ACP_AGENT_COMMAND`). Default: `goose`.
     pub agent_command: String,
@@ -55,18 +51,21 @@ fn parse_keys_var(var: &'static str) -> Result<Keys, ConfigError> {
 
 impl Config {
     /// Load configuration from environment variables.
+    ///
+    /// Key resolution order:
+    /// 1. `SPROUT_PRIVATE_KEY` — single key for everything (preferred)
+    /// 2. `SPROUT_ACP_PRIVATE_KEY` — legacy harness key (fallback)
     pub fn from_env() -> Result<Self, ConfigError> {
-        let harness_keys = parse_keys_var("SPROUT_ACP_PRIVATE_KEY")?;
+        let keys = parse_keys_var("SPROUT_PRIVATE_KEY")
+            .or_else(|_| parse_keys_var("SPROUT_ACP_PRIVATE_KEY"))?;
 
-        let harness_api_token = std::env::var("SPROUT_ACP_API_TOKEN").ok();
+        let api_token = std::env::var("SPROUT_API_TOKEN")
+            .or_else(|_| std::env::var("SPROUT_ACP_API_TOKEN"))
+            .ok();
 
         let relay_url =
             std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
 
-        let agent_keys = parse_keys_var("SPROUT_AGENT_PRIVATE_KEY")?;
-
-        let agent_api_token = std::env::var("SPROUT_AGENT_API_TOKEN").ok();
-
         let agent_command =
             std::env::var("SPROUT_ACP_AGENT_COMMAND").unwrap_or_else(|_| "goose".to_string());
 
@@ -83,11 +82,9 @@ impl Config {
             .unwrap_or(300);
 
         Ok(Config {
-            harness_keys,
-            harness_api_token,
+            keys,
+            api_token,
             relay_url,
-            agent_keys,
-            agent_api_token,
             agent_command,
             agent_args,
             mcp_command,
@@ -98,10 +95,9 @@ impl Config {
     /// Return a human-readable summary (no secrets).
     pub fn summary(&self) -> String {
         format!(
-            "relay={} harness_pubkey={} agent_pubkey={} agent_cmd={} {} mcp_cmd={} turn_timeout={}s",
+            "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} turn_timeout={}s",
             self.relay_url,
-            self.harness_keys.public_key().to_hex(),
-            self.agent_keys.public_key().to_hex(),
+            self.keys.public_key().to_hex(),
             self.agent_command,
             self.agent_args.join(" "),
             self.mcp_command,
diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs
index 822fea66c6..d312868d1f 100644
--- a/crates/sprout-acp/src/main.rs
+++ b/crates/sprout-acp/src/main.rs
@@ -33,12 +33,12 @@ async fn main() -> Result<()> {
     let mut acp = spawn_and_init(&config).await?;
 
     // ── Step 2: Connect to Sprout relay ──────────────────────────────────────
-    let agent_pubkey_hex = config.agent_keys.public_key().to_hex();
+    let pubkey_hex = config.keys.public_key().to_hex();
     let mut relay = HarnessRelay::connect(
         &config.relay_url,
-        &config.harness_keys,
-        config.harness_api_token.as_deref(),
-        &agent_pubkey_hex,
+        &config.keys,
+        config.api_token.as_deref(),
+        &pubkey_hex,
     )
     .await
     .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?;
@@ -111,8 +111,6 @@ async fn main() -> Result<()> {
                             tracing::error!(
                                 "failed to create session for channel {channel_id}: {e}"
                             );
-                            // Re-queue so events aren't lost on transient session failure.
-                            // Break (not continue) to avoid hot-loop on persistent failure.
                             queue.requeue(batch);
                             queue.mark_complete();
                             break;
@@ -136,27 +134,19 @@ async fn main() -> Result<()> {
                         Ok(Err(AcpError::AgentExited)) => {
                             tracing::error!("agent process exited — respawning");
                             sessions.clear();
-                            // Re-queue so events are retried after respawn.
                             queue.requeue(batch);
                             queue.mark_complete();
-
                             acp = respawn_agent(&config).await?;
-                            // Break inner drain loop — sessions are gone, will recreate on next event
                             break;
                         }
                         Ok(Err(e)) => {
                             tracing::error!("session_prompt error for channel {channel_id}: {e}");
-                            // Re-queue so events aren't lost on transient prompt error.
-                            // Break (not continue) to avoid hot-loop on persistent failure.
                             queue.requeue(batch);
                             queue.mark_complete();
                             sessions.remove(&channel_id);
                             break;
                         }
                         Err(_elapsed) => {
-                            // Turn timeout — cancel the in-flight prompt.
-                            // On timeout the events have been "processed" (agent was working on them)
-                            // so we do NOT requeue — the turn was attempted and timed out.
                             tracing::warn!(
                                 "turn timeout ({}s) for channel {channel_id} — cancelling",
                                 config.turn_timeout_secs
@@ -205,10 +195,6 @@ async fn main() -> Result<()> {
 
 // ── Helper: respawn agent after exit ─────────────────────────────────────────
 
-/// Spawn a fresh agent process, initialize it, and return the new [`AcpClient`].
-///
-/// Logs success/failure at the appropriate level. Returns `Err` only if the
-/// spawn or initialize step fails fatally (caller should propagate to `main`).
 async fn respawn_agent(config: &Config) -> Result<AcpClient> {
     match spawn_and_init(config).await {
         Ok(new_acp) => {
@@ -276,14 +262,10 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
                 },
                 EnvVar {
                     name: "SPROUT_PRIVATE_KEY".into(),
-                    value: config
-                        .agent_keys
-                        .secret_key()
-                        .to_bech32()
-                        .unwrap_or_default(),
+                    value: config.keys.secret_key().to_bech32().unwrap_or_default(),
                 },
             ];
-            if let Some(ref token) = config.agent_api_token {
+            if let Some(ref token) = config.api_token {
                 env.push(EnvVar {
                     name: "SPROUT_API_TOKEN".into(),
                     value: token.clone(),

From 459a4852c5ac1aa77370a90e6ac5d89ad4cbb4b3 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@block.xyz>
Date: Mon, 9 Mar 2026 22:16:55 -0400
Subject: [PATCH 7/8] docs: fix 4 gaps found by second cold-start tester

- Fix DB queries using nonexistent nostr_keys table (use channel_members.pubkey directly)
- Clarify codex 426 WebSocket error is expected/non-fatal
- Note hermit node path for claude-agent-acp
- Add stale event replay warning on startup
---
 crates/sprout-acp/README.md  |  6 ++++--
 crates/sprout-acp/TESTING.md | 19 ++++++++++---------
 2 files changed, 14 insertions(+), 11 deletions(-)

diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index 3e6bac0cee..d20be11c19 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -72,7 +72,7 @@ export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
 sprout-acp
 ```
 
-> **API key note:** `codex-acp` checks multiple auth methods. Set `OPENAI_API_KEY` explicitly — without it, the adapter may attempt a ChatGPT WebSocket login that fails in headless mode.
+> **API key note:** `codex-acp` always attempts a ChatGPT WebSocket login first, which logs a `426 Upgrade Required` error. This is expected and non-fatal — it falls back to `OPENAI_API_KEY` automatically. Set `OPENAI_API_KEY` to ensure it has a working fallback.
 
 ## Running with Claude Code
 
@@ -84,7 +84,7 @@ cd /path/to/claude-agent-acp && npm install && npm run build
 
 # Run
 export ANTHROPIC_API_KEY="sk-ant-..."
-export SPROUT_ACP_AGENT_COMMAND="node"
+export SPROUT_ACP_AGENT_COMMAND="node"   # full path if using hermit: /path/to/sprout2/bin/node
 export SPROUT_ACP_AGENT_ARGS="/path/to/claude-agent-acp/dist/index.js"
 
 sprout-acp
@@ -119,6 +119,8 @@ All configuration is via environment variables.
 
 Only one prompt is in flight at a time (globally, not per-session). This matches the concurrency model of current ACP agents.
 
+> **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel.
+
 ## Using Any ACP Agent
 
 The harness works with any agent that implements the [ACP spec](https://agentclientprotocol.com/) over stdio. The requirements are:
diff --git a/crates/sprout-acp/TESTING.md b/crates/sprout-acp/TESTING.md
index 914f6c4996..e779afba8b 100644
--- a/crates/sprout-acp/TESTING.md
+++ b/crates/sprout-acp/TESTING.md
@@ -497,11 +497,12 @@ The harness calls the relay's REST API to discover channels associated with the
 
 ```sql
 -- Check agent channel membership
-SELECT c.uuid, c.name
-FROM channels c
-JOIN channel_members cm ON cm.channel_id = c.id
-JOIN nostr_keys nk ON nk.id = cm.nostr_key_id
-WHERE HEX(nk.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
+SELECT HEX(c.id) as channel_id, c.name
+
+
+
+FROM channels c JOIN channel_members cm ON cm.channel_id = c.id
+WHERE HEX(cm.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
 ```
 
 ---
@@ -678,8 +679,8 @@ ORDER BY e.created_at DESC LIMIT 20;
 SELECT c.uuid, c.name
 FROM channels c
 JOIN channel_members cm ON cm.channel_id = c.id
-JOIN nostr_keys nk ON nk.id = cm.nostr_key_id
-WHERE HEX(nk.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
+
+WHERE HEX(cm.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
 ```
 
 ### Relay Health
@@ -801,8 +802,8 @@ Restart harness after setting.
 SELECT c.uuid, c.name
 FROM channels c
 JOIN channel_members cm ON cm.channel_id = c.id
-JOIN nostr_keys nk ON nk.id = cm.nostr_key_id
-WHERE HEX(nk.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
+
+WHERE HEX(cm.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
 ```
 
 ---

From 40aeae08abe6c00ee82b6e73bb5abaf2c76e291c Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Tue, 10 Mar 2026 22:25:57 -0400
Subject: [PATCH 8/8] feat: canvas REST endpoints, MCP tool fixes, E2E test
 overhaul, TESTING.md
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Canvas REST API:
- GET/PUT /api/channels/{id}/canvas endpoints (canvas.rs)
- Relay-signed events with p-tag author attribution
- Archived channel rejection (403)
- MCP tools migrated from WebSocket to REST with clean string responses

MCP tool description fixes:
- create_channel: document stream/forum and open/private enums
- create_workflow: correct YAML schema (id field, direct properties)
- Removed invalid 'schedule' trigger from docs
- Fixed list_channels terminology (public → open)

E2E test overhaul:
- All 3 test suites (REST, relay, MCP) now self-contained
- Dynamic channel creation per test (no seed data dependency)
- h-tag migration for NIP-29 compliance
- Known keypairs for MCP tests
- Tightened canvas assertions (exact match)

ACP harness fixes:
- Subscription filter: #e → #h (was silently dropping all events)
- mention binary: e tag → h tag, multi-word message support

TESTING.md:
- Rewritten to use sprout-acp harness (not ad-hoc goose scripts)
- Consolidated ACP testing guide into root (deleted crate-level copy)
- 9 advanced ACP scenarios, 36 MCP tool reference, troubleshooting

All tests pass: unit ✅, REST 20/20 ✅, relay 13/13 ✅, MCP 7/7 ✅
Crossfire reviewed: opus 9/10 APPROVE, codex 9/10 APPROVE
---
 TESTING.md                                    | 1331 ++++++++++++++---
 crates/sprout-acp/README.md                   |    2 +-
 crates/sprout-acp/TESTING.md                  |  986 ------------
 crates/sprout-acp/src/relay.rs                |    2 +-
 crates/sprout-mcp/src/lib.rs                  |    2 +-
 crates/sprout-mcp/src/relay_client.rs         |   13 +
 crates/sprout-mcp/src/server.rs               |  101 +-
 crates/sprout-relay/src/api/canvas.rs         |  160 ++
 crates/sprout-relay/src/api/mod.rs            |    3 +
 crates/sprout-relay/src/router.rs             |    5 +
 crates/sprout-test-client/src/bin/mention.rs  |    6 +-
 crates/sprout-test-client/tests/e2e_mcp.rs    |  224 ++-
 crates/sprout-test-client/tests/e2e_relay.rs  |   45 +-
 .../sprout-test-client/tests/e2e_rest_api.rs  |  163 +-
 14 files changed, 1705 insertions(+), 1338 deletions(-)
 delete mode 100644 crates/sprout-acp/TESTING.md
 create mode 100644 crates/sprout-relay/src/api/canvas.rs

diff --git a/TESTING.md b/TESTING.md
index 6ee4592665..8c61782944 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -1,322 +1,1271 @@
-# Sprout — Local Testing Guide
+# Sprout Testing Guide
 
-How to run a local Sprout instance and test it with multiple goose agents communicating over the relay.
+This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 36 MCP tools against a live relay.
 
----
+## Two Test Modes
+
+| Mode | What It Does | When to Use |
+|------|-------------|-------------|
+| **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions |
+| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 36 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features |
 
-## 1. Overview
+Run both modes for a complete regression check. Run automated-only for a fast sanity check.
 
-This guide walks through:
-1. Starting the backing services (MySQL, Redis, Typesense) via Docker Compose
-2. Building and running the relay server
-3. Creating test channels and adding members via SQL
-4. Minting API tokens for each agent via `sprout-admin`
-5. Launching goose agents with the `sprout-mcp` extension
-6. Verifying that agents can send and receive messages
-7. Running the automated test suite (unit + integration + e2e)
+---
 
-**Outcome:** Two or more goose agents connected to a local relay, exchanging messages through a shared channel, with all traffic verifiable in relay logs and the database.
+## Table of Contents
+
+1. [Prerequisites](#1-prerequisites)
+2. [Quick Start: Automated Tests Only](#2-quick-start-automated-tests-only)
+3. [Multi-Agent E2E Testing](#3-multi-agent-e2e-testing)
+   - [3.1 Architecture](#31-architecture)
+   - [3.2 Infrastructure Setup](#32-infrastructure-setup)
+   - [3.3 Mint Agent Keys](#33-mint-agent-keys)
+   - [3.4 Launch Harness Instances](#34-launch-harness-instances)
+   - [3.5 Test Exercises](#35-test-exercises)
+   - [3.6 Monitoring & Verification](#36-monitoring--verification)
+   - [3.7 Expected Results](#37-expected-results)
+4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios)
+5. [Workflow YAML Reference](#5-workflow-yaml-reference)
+6. [The 36 MCP Tools](#6-the-36-mcp-tools)
+7. [Cleanup](#7-cleanup)
+8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting)
 
 ---
 
-## 2. Prerequisites
+## 1. Prerequisites
+
+Verify each requirement before proceeding. All commands must succeed.
+
+### Docker
+
+```bash
+docker --version
+# Required: any recent version
+
+docker compose version
+# Required: v2+ (uses "docker compose", not "docker-compose")
+```
 
-| Requirement | Version | Notes |
-|-------------|---------|-------|
-| Docker + Docker Compose | 24+ | `docker compose` (v2 plugin) |
-| Rust toolchain | 1.88+ | via [Hermit](https://cashapp.github.io/hermit/) or `rustup` |
-| goose CLI | latest | `goose --version` |
-| `mysql` client | any | for running SQL commands; or use Adminer at http://localhost:8082 |
+### Rust 1.88+
 
-**Hermit (recommended):** If the repo has a `.hermit/` directory, activate it with `. bin/activate-hermit` — this pins the exact Rust version.
+```bash
+# From the sprout repo root — use Hermit if system Rust is older than 1.88
+. bin/activate-hermit
+
+rustc --version
+# Required: rustc 1.88.0 or newer
+```
+
+### goose CLI
+
+```bash
+goose --version
+# Must be on $PATH and configured with a valid provider/model
+
+goose run --help | head -5
+# Must not error
+```
+
+### sqlx-cli
+
+```bash
+sqlx --version
+# If missing:
+cargo install sqlx-cli --no-default-features --features mysql
+```
+
+### screen
+
+```bash
+screen --version
+# Must print a version string (note: on macOS this exits with code 1 — that's fine)
+# If missing: brew install screen
+```
+
+### All clear
+
+If all commands above print version info, proceed. If any binary is missing, install it first — the tests will not work without all prerequisites.
 
 ---
 
-## 3. Start Infrastructure
+## 2. Quick Start: Automated Tests Only
+
+Run this when you want a fast check without spinning up multi-agent infrastructure.
 
 ```bash
-cd REPOS/sprout
+# Enter the repo and activate toolchain FIRST — all subsequent commands
+# assume you are in the sprout repo root with hermit activated.
+cd /path/to/sprout   # e.g. ~/Development/goosetown_oss/REPOS/sprout
+. bin/activate-hermit
+```
 
-# Copy env config (only needed once)
-cp .env.example .env
+### Check for existing infrastructure
 
-# Start MySQL, Redis, Typesense, and Adminer
+If Docker services or a relay are already running from a previous session, you can
+leave the Docker services up and just reset the database and relay:
+
+```bash
+# Kill any existing relay
+screen -S relay -X quit 2>/dev/null
+lsof -ti :3000 | xargs kill -9 2>/dev/null
+
+# Check Docker services — if already running, skip `docker compose up`
+docker compose ps --format '{{.Name}} {{.Status}}' 2>/dev/null
+# If mysql/redis/typesense show "Up", you can skip to "Setup and build" below.
+# If not running:
 docker compose up -d
+```
 
-# Verify all services are healthy
-docker compose ps
+> **Port conflicts:** If `docker compose up -d` fails with "port already allocated",
+> a container from another project may be using the port. Find it with
+> `docker ps --format '{{.Names}} {{.Ports}}'` and stop it manually.
+
+> **Keycloak:** You may see `sprout-keycloak` as `unhealthy` or `starting` — this
+> is fine. Keycloak is only needed for token-based auth and is not required for
+> automated tests (which use dev-mode `X-Pubkey` header auth). You may also see
+> extra containers like `sprout-postgres` from other projects — ignore them.
+
+### Setup and build
+
+```bash
+# Configure environment
+[ -f .env ] || cp .env.example .env
+# Load env vars — ALWAYS required, even if .env already existed
+export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null
+
+# Reset database (fresh state for tests)
+docker exec sprout-mysql mysql -u root -psprout_dev -e \
+  "DROP DATABASE IF EXISTS sprout; CREATE DATABASE sprout;" 2>/dev/null
+sqlx migrate run --database-url "$DATABASE_URL"
+
+# Build the full workspace (relay, MCP server, ACP harness, test client, etc.)
+cargo build --release --workspace
+
+# Run unit tests
+cargo test --workspace
+```
+
+### Integration Tests (require running relay)
+
+Start the relay (kill any stale instance first):
+
+```bash
+screen -S relay -X quit 2>/dev/null
+lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1
+screen -dmS relay bash -c \
+  'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
+   ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
+sleep 3 && curl -s http://localhost:3000/health
+# Must print: ok
 ```
 
-Expected output — all services should show `healthy`:
+Then run the integration suites:
+
+```bash
+# REST API integration tests (20 tests)
+RELAY_URL=ws://localhost:3000 \
+  cargo test -p sprout-test-client --test e2e_rest_api -- --ignored
+
+# WebSocket relay integration tests (13 tests)
+RELAY_URL=ws://localhost:3000 \
+  cargo test -p sprout-test-client --test e2e_relay -- --ignored
+
+# MCP server integration tests (7 tests)
+RELAY_URL=ws://localhost:3000 \
+  cargo test -p sprout-test-client --test e2e_mcp -- --ignored
+```
+
+### Expected Results
+
 ```
-NAME                STATUS
-sprout-mysql        running (healthy)
-sprout-redis        running (healthy)
-sprout-typesense    running (healthy)
-sprout-adminer      running
+test result: ok. 20 passed; 0 failed; 0 ignored   ← REST API
+test result: ok. 13 passed; 0 failed; 0 ignored   ← relay
+test result: ok.  7 passed; 0 failed; 0 ignored   ← MCP
 ```
 
-> **Tip:** If services aren't healthy after ~30 seconds, check logs:
-> `docker compose logs mysql` or `docker compose logs redis`
+All 40 integration tests pass. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.
+
+---
+
+## 3. Multi-Agent E2E Testing
+
+### 3.1 Architecture
+
+The E2E suite uses the `sprout-acp` harness — a process that bridges Sprout relay events to AI agents over the ACP protocol. The operator sends `@mention` events via the `mention` binary; each harness instance picks up mentions targeting its agent's pubkey and forwards them to a goose session with Sprout MCP tools pre-configured.
+
+```
+Operator (you)
+    │
+    │  mention <channel> <pubkey> "task instructions"
+    ▼
+Sprout Relay  ──WS (NIP-01)──►  sprout-acp (harness)  ──stdio (ACP)──►  goose
+                                                                            │
+                                                                       sprout-mcp-server
+                                                                        (36 MCP tools)
+                                                                            │
+                                                                       Sprout Relay
+                                                                    (send_message, etc.)
+```
+
+Three harness instances run simultaneously — one each for Alice, Bob, and Charlie. Each has its own Nostr keypair (identity) and responds only to `@mentions` targeting its pubkey.
+
+**Key properties of the harness:**
+- Discovers and subscribes to all accessible channels on startup
+- Queues events per channel; one prompt in flight globally at a time
+- Batches multiple rapid `@mentions` into a single prompt
+- Auto-respawns the agent subprocess on crash
+- Reconnects to the relay with a `since` filter on disconnect (no missed events)
+- `GOOSE_MODE=auto` is **mandatory** — prevents goose from pausing for permission prompts
+
+### 3.2 Infrastructure Setup
+
+Run all commands from the sprout repo root.
 
-**Run migrations:**
 ```bash
-just migrate
+cd /path/to/sprout
+. bin/activate-hermit
+
+# 1. Start Docker services (MySQL, Redis, Typesense, Keycloak)
+docker compose down -v && docker compose up -d
+docker compose ps   # All services should show "Up"
+
+# 2. Configure environment
+[ -f .env ] || cp .env.example .env
+export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null
+
+# 3. Run database migrations
+sqlx migrate run --database-url "$DATABASE_URL"
+
+# 4. Build all binaries (sprout-acp, sprout-mcp-server, mention, sprout-admin)
+cargo build --release --workspace
+
+# 5. Add release binaries to PATH
+export PATH="$PWD/target/release:$PATH"
+
+# 6. Verify key binaries are present
+ls -la target/release/sprout-acp target/release/sprout-mcp-server \
+        target/release/mention target/release/sprout-admin
+
+# 7. Start the relay
+lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1
+screen -dmS relay bash -c \
+  'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
+   ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
+sleep 3
+
+# 8. Verify relay is up
+curl -s http://localhost:3000/health
+# Expected: {"status":"ok"} or similar
 ```
 
-Expected:
+### 3.3 Mint Agent Keys
+
+Each agent needs its own Nostr keypair. Use `sprout-admin` to mint them — it handles all database interaction internally.
+
+```bash
+# Mint keys for all three agents
+for agent in alice bob charlie; do
+  echo "=== $agent ==="
+  cargo run -p sprout-admin -- mint-token \
+    --name "$agent" \
+    --scopes "messages:read,messages:write,channels:read"
+  echo ""
+done
+```
+
+Each invocation prints an `nsec1...` private key, the corresponding pubkey hex, and an API token. **Save all three sets immediately — they are shown only once.**
+
+Set environment variables for the session:
+
+```bash
+# Replace with actual values from mint-token output
+export ALICE_NSEC="nsec1..."
+export ALICE_PUBKEY="<alice-pubkey-hex>"
+
+export BOB_NSEC="nsec1..."
+export BOB_PUBKEY="<bob-pubkey-hex>"
+
+export CHARLIE_NSEC="nsec1..."
+export CHARLIE_PUBKEY="<charlie-pubkey-hex>"
 ```
-Running migrations via sqlx...
-Applied 1 migration(s).
+
+> **Tip:** Pipe the mint output to a temp file during setup:
+> `cargo run -p sprout-admin -- mint-token --name alice ... | tee /tmp/alice-keys.txt`
+
+### 3.4 Launch Harness Instances
+
+Start one `sprout-acp` instance per agent in a dedicated screen session. `GOOSE_MODE=auto` is required on all three.
+
+```bash
+# Alice's harness
+SPROUT_PRIVATE_KEY="$ALICE_NSEC" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS agent-alice bash -c \
+    'sprout-acp 2>&1 | tee /tmp/agent-alice.log'
+
+# Bob's harness
+SPROUT_PRIVATE_KEY="$BOB_NSEC" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS agent-bob bash -c \
+    'sprout-acp 2>&1 | tee /tmp/agent-bob.log'
+
+# Charlie's harness
+SPROUT_PRIVATE_KEY="$CHARLIE_NSEC" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS agent-charlie bash -c \
+    'sprout-acp 2>&1 | tee /tmp/agent-charlie.log'
 ```
 
-> **Alternative (no sqlx CLI):** `just migrate` falls back to `docker exec` automatically.
+Wait ~5 seconds for all three to connect, then verify:
+
+```bash
+sleep 5
+
+for agent in alice bob charlie; do
+  echo "=== agent-$agent ==="
+  grep -E "connected|discovered|subscribed|error" /tmp/agent-$agent.log 2>/dev/null \
+    || echo "(no log yet)"
+  echo ""
+done
+```
+
+Expected startup output for each harness:
+
+```
+sprout-acp starting: relay=ws://localhost:3000 harness_pubkey=... agent_pubkey=<hex>
+agent initialized: ...
+connected to relay at ws://localhost:3000
+discovered N channel(s)
+subscribed to channel <uuid>
+```
+
+If you see `discovered 0 channel(s)`, the agent is not yet a member of any channels. Alice will create channels in the first exercise — after that, all three will discover them on subsequent subscriptions (open channels are accessible to any authenticated pubkey).
 
 ---
 
-## 4. Build and Run the Relay
+### 3.5 Test Exercises
+
+All exercises are delivered via `@mention` events using the `mention` binary:
 
-> ⚠️ **Port 3000 conflict:** The relay binds to `0.0.0.0:3000` by default. If another process is using port 3000 (e.g., a Node.js dev server), set `SPROUT_BIND_ADDR=0.0.0.0:3001` in `.env` and update `RELAY_URL=ws://localhost:3001`.
+```
+mention <channel_uuid> <target_pubkey_hex> "task instructions"
+```
+
+The `mention` binary generates ephemeral sender keys — it does not need its own nsec. It requires `SPROUT_RELAY_URL` (defaults to `ws://localhost:3000`).
 
-> ⚠️ **`.env` and `cargo run`:** `just relay` uses `set dotenv-load := true` so env vars are loaded automatically. If you run `cargo run -p sprout-relay` directly, the `.env` file is **not** loaded — export vars manually or use `just relay`.
+**Important:** Channel UUIDs are dynamic. Alice creates the channels in Exercise A-1. After that step completes, query the REST API to get the UUIDs before proceeding with other exercises.
 
 ```bash
-# Build the workspace first (catches compile errors early)
-cargo build --workspace
+# Helper: get channel UUID by name (run after Alice creates channels)
+get_channel_uuid() {
+  local name="$1"
+  curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+    "http://localhost:3000/api/channels" \
+    | jq -r ".[] | select(.name == \"$name\") | .id"
+}
+```
+
+---
 
-# Run the relay in a detached screen session
-screen -dmS sprout-relay just relay
+#### Alice — Infrastructure Creator
+
+Alice sets up the shared environment that Bob and Charlie will use.
+
+**A-1: Create channels and seed messages**
+
+Alice needs a bootstrap channel to receive her first `@mention`. Use the default test channel from the relay, or create one via the REST API first:
+
+```bash
+# Create a bootstrap channel for Alice's first mention
+BOOTSTRAP_CHANNEL=$(curl -s -X POST \
+  -H "Content-Type: application/json" \
+  -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels" \
+  -d '{"name":"bootstrap","channel_type":"stream","visibility":"open"}' \
+  | jq -r '.id')
+echo "Bootstrap channel: $BOOTSTRAP_CHANNEL"
 ```
 
-Verify the relay is listening:
+Then send Alice her first task:
+
 ```bash
-screen -r sprout-relay
-# Press Ctrl-A D to detach without stopping
+mention "$BOOTSTRAP_CHANNEL" "$ALICE_PUBKEY" \
+  "Create 3 channels: 'general' (stream/open), 'alice-testing' (stream/open), and 'private-ops' (stream/private). Then send 3 messages to the 'general' channel introducing yourself and describing what you're testing."
 ```
 
-Expected log output:
+Wait for Alice to complete (~30–60s), then capture channel UUIDs:
+
+```bash
+sleep 60
+export GENERAL=$(get_channel_uuid "general")
+export ALICE_TESTING=$(get_channel_uuid "alice-testing")
+export PRIVATE_OPS=$(get_channel_uuid "private-ops")
+echo "general=$GENERAL  alice-testing=$ALICE_TESTING  private-ops=$PRIVATE_OPS"
 ```
-INFO sprout_relay: listening on 0.0.0.0:3000
-WARN sprout_relay: SPROUT_REQUIRE_AUTH_TOKEN is false — relay accepts unauthenticated connections.
+
+**A-2: Channel metadata and canvas**
+
+```bash
+mention "$GENERAL" "$ALICE_PUBKEY" \
+  "Set the topic on the 'general' channel to 'Sprout E2E Testing'. Set the purpose to 'Multi-agent integration test run'. Then set the canvas on 'general' to a markdown document with a header '# Test Run Notes' and a bullet list of the 3 channels you created."
 ```
 
-> The auth warning is expected in local dev. Set `SPROUT_REQUIRE_AUTH_TOKEN=true` in `.env` to enforce token auth.
+**A-3: Thread and reactions**
+
+```bash
+mention "$GENERAL" "$ALICE_PUBKEY" \
+  "Get the history of the 'general' channel. Reply to your first message there with a thread reply saying 'This is a thread reply from Alice'. Then add a 👍 reaction and a 🚀 reaction to your own first message."
+```
+
+**A-4: Workflow creation**
+
+```bash
+mention "$GENERAL" "$ALICE_PUBKEY" \
+  "Create a workflow named 'alice-notify' with a message_posted trigger on the 'general' channel. The workflow should have one step: send a message to the 'general' channel saying 'Workflow fired!'. Save the workflow ID and report it back."
+```
+
+**A-5: Profile and presence**
+
+```bash
+mention "$GENERAL" "$ALICE_PUBKEY" \
+  "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your presence to online."
+```
+
+**A-6: Feed, search, and membership**
+
+```bash
+mention "$GENERAL" "$ALICE_PUBKEY" \
+  "Get your feed and the channel feed for 'general'. Search for messages containing the word 'Alice'. List the members of the 'general' channel. Then invite Bob (pubkey: $BOB_PUBKEY) to the 'private-ops' channel."
+```
 
 ---
 
-## 5. Create Test Channels
+#### Bob — Discoverer and Reactor
+
+Bob explores the environment Alice created and interacts with her content.
 
-Connect to MySQL and create a channel, then add members after minting tokens (step 6 gives you pubkeys).
+**B-1: Discovery and history**
 
 ```bash
-mysql -u sprout -psprout_dev -h 127.0.0.1 sprout
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "List all channels you have access to. Get the message history from the 'general' channel (last 20 messages). Report what you find — how many channels exist, and what did Alice write in general?"
 ```
 
-```sql
--- Create a test channel (channel ID must be a 16-byte UUID stored as BINARY(16))
-INSERT INTO channels (id, name, channel_type, visibility, created_by)
-VALUES (
-    UNHEX(REPLACE(UUID(), '-', '')),
-    'agent-test',
-    'stream',
-    'open',
-    X'0000000000000000000000000000000000000000000000000000000000000001'
-);
+**B-2: Reactions and DM**
 
--- Capture the channel ID for later steps
-SELECT HEX(id) AS channel_id, name FROM channels WHERE name = 'agent-test';
+```bash
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "React to the first message in the 'general' channel with a ❤️ reaction. Then send a direct message to Alice (pubkey: $ALICE_PUBKEY) saying 'Hi Alice, Bob here — I can see your channels and messages. The setup looks great!'"
 ```
 
-> **Note:** `channel_members` entries require a valid `pubkey` (32-byte Nostr public key). Add members **after** minting tokens in step 6.
+**B-3: DM history and canvas**
+
+```bash
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "Get your DM conversation history with Alice. Read the canvas on the 'general' channel and report what it says. List all your DM conversations."
+```
+
+**B-4: Thread participation**
+
+```bash
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "Find Alice's thread in the 'general' channel (a message that has replies). Add a thread reply saying 'Bob joining the thread — everything looks good from my end.' Then get the thread replies and report how many there are."
+```
+
+**B-5: Channel join and profile**
+
+```bash
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "Join the 'alice-testing' channel. Get your own profile and set your display name to 'Bob (Test Agent)'. Search for any messages mentioning 'workflow' or 'canvas'."
+```
+
+**B-6: Private channel access test**
+
+```bash
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "Try to get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Report whether you can access it or get an error. Then try again — Alice should have invited you by now."
+```
+
+**B-7: Get presence**
+
+```bash
+mention "$GENERAL" "$BOB_PUBKEY" \
+  "Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both."
+```
 
 ---
 
-## 6. Mint Agent Tokens
+#### Charlie — Edge Case Specialist
 
-`sprout-admin` creates API tokens and optionally generates a new Nostr keypair per agent. Run once per agent.
+Charlie tests error handling, idempotency, and lifecycle operations.
 
-> ⚠️ **Save the output immediately** — the raw token and private key (`nsec`) are shown only once.
+**C-1: Non-existent channel error**
 
 ```bash
-# Agent 1
-cargo run -p sprout-admin -- mint-token \
-  --name "agent-alice" \
-  --scopes "messages:read,messages:write,channels:read"
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Try to send a message to channel UUID '00000000-0000-0000-0000-000000000000'. Report the exact error you receive."
 ```
 
+**C-2: Unauthorized archive attempt**
+
 ```bash
-# Agent 2
-cargo run -p sprout-admin -- mint-token \
-  --name "agent-bob" \
-  --scopes "messages:read,messages:write,channels:read"
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Try to archive the 'general' channel (ID: $GENERAL). You did not create it — report what error you get."
 ```
 
-Expected output (per agent):
+**C-3: Canvas overwrite**
+
+```bash
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Set the canvas on the 'general' channel to a new markdown document: '# Charlie Was Here\n\nCharlie overwrote the canvas on $(date -u +%Y-%m-%dT%H:%M:%SZ)'. Then immediately read the canvas back and confirm it shows your content."
+```
+
+**C-4: Reaction idempotency**
+
+```bash
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Find the first message in the 'general' channel. Add a 🎉 reaction to it. Then try to add the same 🎉 reaction again. Report what happens the second time — does it error or succeed silently?"
 ```
-╔══════════════════════════════════════════════════════════════╗
-║  Token minted successfully!                                 ║
-╠══════════════════════════════════════════════════════════════╣
-║  Token ID:    <uuid>                                        ║
-║  Name:        agent-alice                                   ║
-║  Scopes:      messages:read,messages:write,channels:read    ║
-║  Pubkey:      <first 48 hex chars>...                       ║
-╠══════════════════════════════════════════════════════════════╣
-║  ⚠️  SAVE THESE — shown only once!                          ║
-╠══════════════════════════════════════════════════════════════╣
-║  Private key (nsec):                                        ║
-║  nsec1...                                                   ║
-║                                                              ║
-║  API Token:                                                  ║
-║  spr_...                                                     ║
-╚══════════════════════════════════════════════════════════════╝
+
+**C-5: Channel lifecycle (create → archive → send → unarchive → send)**
+
+```bash
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Create a new channel named 'charlie-lifecycle' (stream/open). Send a message to it saying 'Before archive'. Archive the channel. Try to send another message — report the error. Unarchive the channel. Send a message saying 'After unarchive'. Confirm the final message was accepted."
 ```
 
-**Add agents as channel members** (using the full pubkey hex from the output):
-```sql
--- In mysql client — replace <PUBKEY_HEX> with each agent's full 64-char hex pubkey
-INSERT INTO channel_members (channel_id, pubkey, role)
-SELECT id, UNHEX('<ALICE_PUBKEY_HEX>'), 'member'
-FROM channels WHERE name = 'agent-test';
+**C-6: Join, send, leave, verify**
 
-INSERT INTO channel_members (channel_id, pubkey, role)
-SELECT id, UNHEX('<BOB_PUBKEY_HEX>'), 'member'
-FROM channels WHERE name = 'agent-test';
+```bash
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Join the 'alice-testing' channel. Send a message there saying 'Charlie was here'. Then leave the channel. Try to send another message to 'alice-testing' — report whether it succeeds or fails after leaving."
 ```
 
-**List all tokens** to verify:
+**C-7: Workflow trigger and cross-agent summary**
+
 ```bash
-cargo run -p sprout-admin -- list-tokens
+mention "$GENERAL" "$CHARLIE_PUBKEY" \
+  "Get the list of workflows. Find Alice's 'alice-notify' workflow and trigger it via webhook if it has a webhook trigger, or note that it uses message_posted. Then get the presence for both Alice (pubkey: $ALICE_PUBKEY) and Bob (pubkey: $BOB_PUBKEY). Finally, produce a summary report in the 'general' channel listing: (1) all channels created during this test run, (2) total messages sent, (3) any errors encountered."
 ```
 
 ---
 
-## 7. Launch Agents
+### 3.6 Monitoring & Verification
+
+#### Watch harness logs live
+
+```bash
+# Tail the log files (agent-safe — no TTY required)
+tail -f /tmp/agent-alice.log &
+tail -f /tmp/agent-bob.log &
+tail -f /tmp/agent-charlie.log &
+
+# Or view recent output from a specific agent
+tail -50 /tmp/agent-alice.log
+```
+
+> **Note:** Do NOT use `screen -r` to attach to harness sessions if you are an
+> AI agent — it requires an interactive TTY and will hang indefinitely. Always
+> use `tail` on the log files or `grep` for specific patterns instead.
+
+Key log patterns to watch for:
+
+```
+# Good — turn completed
+turn complete for channel <uuid>: end_turn
+
+# Good — agent is working
+prompting agent for channel <uuid> (session ..., N event(s))
 
-Each agent runs in its own terminal with its own token and private key. The `sprout-mcp` extension connects to the relay via stdio transport.
+# Investigate — agent had trouble
+turn complete for channel <uuid>: max_tokens
+turn timeout (300s) for channel <uuid> — cancelling
 
-**Environment variables for `sprout-mcp`:**
+# Bad — needs attention
+agent process exited — respawning
+relay connection lost — reconnecting
+```
 
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `SPROUT_RELAY_URL` | WebSocket URL of the relay | `ws://localhost:3000` |
-| `SPROUT_API_TOKEN` | API token from step 6 | (none — unauthenticated) |
-| `SPROUT_PRIVATE_KEY` | Nostr private key (`nsec1...`) | generates ephemeral key |
+#### Tail log files
 
-**Terminal 1 — Agent Alice:**
 ```bash
-SPROUT_RELAY_URL=ws://localhost:3000 \
-SPROUT_API_TOKEN=spr_<alice-token> \
-SPROUT_PRIVATE_KEY=nsec1<alice-key> \
-goose run --no-profile \
-  --with-extension "cargo run -p sprout-mcp" \
-  --instructions "You are Alice. Join the agent-test channel and say hello."
+# All three agents at once
+tail -f /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log
+
+# Filter for completions only
+grep "turn complete\|turn timeout\|turn cancelled" \
+  /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log
 ```
 
-**Terminal 2 — Agent Bob:**
+#### REST API verification
+
 ```bash
-SPROUT_RELAY_URL=ws://localhost:3000 \
-SPROUT_API_TOKEN=spr_<bob-token> \
-SPROUT_PRIVATE_KEY=nsec1<bob-key> \
-goose run --no-profile \
-  --with-extension "cargo run -p sprout-mcp" \
-  --instructions "You are Bob. Join the agent-test channel and respond to Alice."
+# List all channels (use any agent's pubkey)
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels" \
+  | jq '.[] | {id: .id, name: .name, visibility: .visibility}'
+
+# Recent messages in general
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels/$GENERAL/messages?limit=20" \
+  | jq '.[] | {sender: .pubkey[:16], body: .content[:120]}'
+
+# Messages from a specific agent
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels/$GENERAL/messages?limit=50" \
+  | jq --arg pk "$CHARLIE_PUBKEY" \
+    '[.[] | select(.pubkey == $pk)] | {count: length, messages: [.[] | .content[:100]]}'
+
+# Channel members
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels/$GENERAL/members" \
+  | jq '.[] | {pubkey: .pubkey[:16], role: .role}'
+
+# Check private-ops membership (Bob should be there after A-6)
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels/$PRIVATE_OPS/members" \
+  | jq '.[] | .pubkey[:16]'
 ```
 
-> **Note:** `cargo run -p sprout-mcp` builds and runs the MCP server inline. For faster startup after the first build, use the compiled binary: `./target/debug/sprout-mcp-server`.
+#### Screen session management
+
+```bash
+# List all active sessions
+screen -ls
+
+# Check if a session is still running
+screen -ls | grep agent-alice
+
+# Capture current screen contents to file (non-destructive)
+screen -S agent-alice -X hardcopy /tmp/alice-snapshot.txt
+cat /tmp/alice-snapshot.txt
+```
+
+### 3.7 Expected Results
+
+After all exercises complete, the following should be true:
+
+| Check | Expected |
+|-------|----------|
+| Channels created | At least 5: general, alice-testing, private-ops, charlie-lifecycle, bootstrap |
+| Messages in general | 10+ messages from Alice, Bob, and Charlie |
+| Thread replies | At least 2 replies on Alice's first message |
+| Reactions | 👍 🚀 (Alice), ❤️ (Bob), 🎉 (Charlie) on general messages |
+| Canvas | Charlie's content (last writer wins) |
+| DM conversation | Alice ↔ Bob DM exists |
+| Bob in private-ops | Yes (Alice invited him in A-6) |
+| Workflow | alice-notify created with message_posted trigger |
+| Display names | Alice and Bob have display names set |
+| Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors |
+| charlie-lifecycle | Unarchived and final message sent successfully |
+
+**Verify the full picture:**
+
+```bash
+# Channel count
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels" | jq 'length'
+
+# Message count in general
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels/$GENERAL/messages?limit=200" | jq 'length'
+
+# Workflow list
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/workflows" \
+  | jq '.[] | {name: .name, trigger: .trigger.on}'
+```
+
+---
+
+## 4. Advanced: ACP Harness Scenarios
+
+These scenarios test the `sprout-acp` harness itself — crash recovery, relay reconnection, turn timeout, and concurrent multi-agent operation. Run them independently from the main E2E suite.
+
+### Prerequisites for Advanced Scenarios
+
+```bash
+# Use the single-agent test key from sprout-acp TESTING.md
+export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
+export AGENT_PUBKEY=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb
+export TEST_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301
+
+# Start a single harness
+SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
+sleep 5
+```
+
+### Scenario A: Basic @mention → Agent Replies
+
+```bash
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "What is 2 + 2? Reply with just the number."
+sleep 30
+
+# Verify reply via REST API
+curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
+  "http://localhost:3000/api/channels/$TEST_CHANNEL/messages?limit=5" \
+  | jq --arg pk "$AGENT_PUBKEY" \
+    '.[] | select(.pubkey == $pk) | {body: .content[:200]}'
+```
+
+Expected: agent replies with "4" via `send_message`.
+
+### Scenario B: Multi-Event Batch
+
+```bash
+# Send 3 mentions in rapid succession
+for i in 1 2 3; do
+  mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Batch message $i" &
+done
+wait
+sleep 30
+
+# Check harness log for batch size
+grep "prompting agent" /tmp/harness.log | tail -5
+# Look for "(session ..., N event(s))" where N > 1
+```
+
+### Scenario C: Agent Crash Recovery
+
+```bash
+# Kill the agent subprocess
+kill -9 $(pgrep -f "goose acp") 2>/dev/null
+
+# Send a new mention
+sleep 2
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Are you still alive after the crash?"
+sleep 30
+
+# Verify recovery in logs
+grep -E "agent process exited|agent respawned|turn complete" /tmp/harness.log | tail -10
+```
+
+Expected log sequence: `agent process exited — respawning` → `agent respawned successfully` → `agent initialized` → `turn complete`.
+
+### Scenario D: Relay Disconnect Recovery
+
+```bash
+# Stop the relay
+screen -S relay -X stuff $'\003'   # Ctrl-C
+sleep 2
+
+# Watch harness detect disconnect
+grep "relay connection lost\|reconnecting" /tmp/harness.log
+
+# Restart relay
+screen -dmS relay bash -c \
+  'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
+   ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
+sleep 5
+
+# Verify reconnection
+grep "relay reconnected\|subscribed" /tmp/harness.log | tail -5
+
+# Confirm harness is functional again
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Post-reconnect test — reply with OK"
+sleep 30
+grep "turn complete" /tmp/harness.log | tail -3
+```
+
+### Scenario E: Turn Timeout
+
+```bash
+# Restart harness with 5-second timeout
+screen -S harness -X quit
+SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+SPROUT_ACP_TURN_TIMEOUT=5 \
+GOOSE_MODE=auto \
+  screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
+sleep 3
+
+# Send a prompt that will take longer than 5 seconds
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY" \
+  "Write a detailed 500-word essay on the history of computing, then list 50 prime numbers."
+sleep 15
+
+# Verify timeout was triggered
+grep "turn timeout\|turn cancelled" /tmp/harness.log | tail -5
+
+# Reset to normal timeout
+screen -S harness -X quit
+SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
+```
+
+Expected: `turn timeout (5s) for channel ... — cancelling` then `turn cancelled for channel ...`. Harness continues running.
+
+### Scenario F: Permission Handling (GOOSE_MODE=auto)
+
+```bash
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY" \
+  "Use your tools to get the last 5 messages from this channel and summarize them."
+sleep 60
+
+# Verify no permission prompts appeared
+grep -i "permission\|approval\|waiting" /tmp/harness.log | head -5
+# Should return nothing
+
+grep "turn complete" /tmp/harness.log | tail -3
+# Should show end_turn
+```
+
+### Scenario G: Channel Discovery
+
+```bash
+# Restart harness fresh and watch discovery
+screen -S harness -X quit
+SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
+sleep 5
+
+grep "discovered\|subscribed" /tmp/harness.log
+# Expected: "discovered N channel(s)" then "subscribed to channel <uuid>" for each
+```
+
+Verify channel membership via REST API:
+
+```bash
+curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
+  "http://localhost:3000/api/channels" \
+  | jq '.[] | {id: .id, name: .name}'
+```
+
+### Scenario H: Concurrent Channels (FIFO Fairness)
+
+```bash
+# Get a second channel UUID (create one if needed)
+CHANNEL_B=$(curl -s -X POST \
+  -H "Content-Type: application/json" \
+  -H "X-Pubkey: $AGENT_PUBKEY" \
+  "http://localhost:3000/api/channels" \
+  -d '{"name":"channel-b-test","channel_type":"stream","visibility":"open"}' \
+  | jq -r '.id')
+
+# Send to channel A first, then B immediately
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY" "Channel A message — process me first"
+mention "$CHANNEL_B" "$AGENT_PUBKEY" "Channel B message — process me second"
+sleep 60
+
+# Verify FIFO ordering in logs
+grep "prompting agent for channel" /tmp/harness.log | tail -5
+# Channel A should appear before Channel B
+# No two "prompting agent" lines without a "turn complete" between them
+```
+
+### Scenario I: Multi-Agent (3 Agents, 1 Channel)
+
+```bash
+# Mint two additional keypairs
+cargo run -p sprout-admin -- mint-token \
+  --name "agent-b" --scopes "messages:read,messages:write,channels:read" \
+  | tee /tmp/agent-b-keys.txt
+
+cargo run -p sprout-admin -- mint-token \
+  --name "agent-c" --scopes "messages:read,messages:write,channels:read" \
+  | tee /tmp/agent-c-keys.txt
+
+# Extract keys (adjust parsing as needed based on output format)
+AGENT_B_NSEC=$(grep "nsec1" /tmp/agent-b-keys.txt | awk '{print $NF}')
+AGENT_B_PUBKEY=$(grep "pubkey" /tmp/agent-b-keys.txt | awk '{print $NF}')
+AGENT_C_NSEC=$(grep "nsec1" /tmp/agent-c-keys.txt | awk '{print $NF}')
+AGENT_C_PUBKEY=$(grep "pubkey" /tmp/agent-c-keys.txt | awk '{print $NF}')
+
+# Start three harnesses
+SPROUT_PRIVATE_KEY="$SPROUT_PRIVATE_KEY" GOOSE_MODE=auto \
+  screen -dmS harness-a bash -c 'sprout-acp 2>&1 | tee /tmp/harness-a.log'
+
+SPROUT_PRIVATE_KEY="$AGENT_B_NSEC" GOOSE_MODE=auto \
+  screen -dmS harness-b bash -c 'sprout-acp 2>&1 | tee /tmp/harness-b.log'
+
+SPROUT_PRIVATE_KEY="$AGENT_C_NSEC" GOOSE_MODE=auto \
+  screen -dmS harness-c bash -c 'sprout-acp 2>&1 | tee /tmp/harness-c.log'
+
+sleep 5
+
+# Send a targeted @mention to each agent
+mention "$TEST_CHANNEL" "$AGENT_PUBKEY"  "Hello agent-a, reply with PONG-A"
+mention "$TEST_CHANNEL" "$AGENT_B_PUBKEY" "Hello agent-b, reply with PONG-B"
+mention "$TEST_CHANNEL" "$AGENT_C_PUBKEY" "Hello agent-c, reply with PONG-C"
+sleep 60
+
+# Verify three distinct replies
+curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
+  "http://localhost:3000/api/channels/$TEST_CHANNEL/messages?limit=20" \
+  | jq '.[] | {sender: (.pubkey[:16] + "..."), body: .content[:100]}'
+# Look for three distinct sender prefixes, each with a PONG reply
+
+# Cleanup
+for s in harness-a harness-b harness-c; do screen -S $s -X quit; done
+```
+
+---
+
+## 5. Workflow YAML Reference
+
+Workflows are created via the `create_workflow` MCP tool. The YAML structure:
+
+```yaml
+name: My Workflow
+trigger:
+  on: message_posted     # Valid: message_posted | reaction_added | webhook
+  channel_id: "<uuid>"   # Optional: scope to a specific channel
+steps:
+  - id: notify           # Required: alphanumeric + underscores only
+    action: send_message # Action type
+    channel_id: "<uuid>" # Action-specific fields are DIRECT properties (not nested)
+    text: "Workflow fired!"
+```
+
+**Valid triggers:**
+- `message_posted` — fires when any message is posted (optionally scoped to a channel)
+- `reaction_added` — fires when a reaction is added to a message
+- `webhook` — fires when the webhook URL is called via HTTP POST
+
+**Step field rules:**
+- `id` is required on every step (alphanumeric and underscores)
+- Action fields (`channel_id`, `text`, etc.) are **direct properties** of the step object — do NOT nest them under a `params` key
+- `create_workflow` tool accepts the YAML as a string parameter
+
+**Example: message_posted workflow**
+
+```yaml
+name: welcome-new-messages
+trigger:
+  on: message_posted
+  channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301"
+steps:
+  - id: echo_reply
+    action: send_message
+    channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301"
+    text: "New message detected!"
+```
+
+**Example: webhook workflow**
+
+```yaml
+name: external-trigger
+trigger:
+  on: webhook
+steps:
+  - id: notify_channel
+    action: send_message
+    channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301"
+    text: "Webhook triggered this workflow!"
+```
+
+---
+
+## 6. The 36 MCP Tools
+
+The `sprout-mcp-server` exposes 36 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness.
+
+### Channels (8)
+
+| Tool | Description |
+|------|-------------|
+| `list_channels` | List all channels accessible to the agent |
+| `get_channel_info` | Get metadata for a specific channel |
+| `create_channel` | Create a new channel (`channel_type`: stream\|forum, `visibility`: open\|private) |
+| `update_channel` | Update channel name or metadata |
+| `archive_channel` | Archive a channel (creator only) |
+| `unarchive_channel` | Restore an archived channel |
+| `join_channel` | Join an open channel |
+| `leave_channel` | Leave a channel |
+
+### Messages (4)
+
+| Tool | Description |
+|------|-------------|
+| `send_message` | Post a message to a channel |
+| `get_channel_history` | Get recent messages from a channel |
+| `send_thread_reply` | Reply within a message thread |
+| `get_thread_replies` | Get replies in a thread |
+
+### Reactions (3)
+
+| Tool | Description |
+|------|-------------|
+| `add_reaction` | Add an emoji reaction to a message |
+| `remove_reaction` | Remove a reaction |
+| `get_message_reactions` | List all reactions on a message |
+
+### Direct Messages (3)
+
+| Tool | Description |
+|------|-------------|
+| `send_dm` | Send a direct message to a user |
+| `get_dm_history` | Get DM conversation history |
+| `list_dm_conversations` | List all DM conversations |
+
+### Canvas (2)
+
+| Tool | Description |
+|------|-------------|
+| `get_canvas` | Read the canvas document for a channel |
+| `set_canvas` | Write/overwrite the canvas document (last writer wins) |
+
+### Workflows (3)
+
+| Tool | Description |
+|------|-------------|
+| `create_workflow` | Create a new workflow with trigger and steps |
+| `list_workflows` | List all workflows |
+| `trigger_workflow` | Manually trigger a webhook workflow |
+
+### Feed (2)
+
+| Tool | Description |
+|------|-------------|
+| `get_feed` | Get the agent's personal activity feed |
+| `get_channel_feed` | Get the activity feed for a specific channel |
+
+### Search (1)
+
+| Tool | Description |
+|------|-------------|
+| `search` | Full-text search across messages and channels |
+
+### Profile (3)
+
+| Tool | Description |
+|------|-------------|
+| `get_profile` | Get the agent's profile |
+| `set_display_name` | Set the agent's display name |
+| `set_about` | Set the agent's bio/about text |
+
+### Presence (2)
+
+| Tool | Description |
+|------|-------------|
+| `set_presence` | Set the agent's presence status (online/away/etc.) |
+| `get_presence` | Get presence status for a user |
+
+### Members (2)
+
+| Tool | Description |
+|------|-------------|
+| `list_channel_members` | List members of a channel |
+| `get_user_channels` | Get channels a user belongs to |
+
+### Admin (3)
+
+| Tool | Description |
+|------|-------------|
+| `set_channel_topic` | Set the topic for a channel |
+| `set_channel_purpose` | Set the purpose for a channel |
+| `invite_to_channel` | Invite a user (by pubkey) to a channel |
 
 ---
 
-## 8. Verify Conversations
+## 7. Cleanup
+
+### Stop harness instances
 
-**Check relay logs** (in the screen session):
 ```bash
-screen -r sprout-relay
+# E2E test harnesses
+for s in agent-alice agent-bob agent-charlie; do
+  screen -S $s -X quit 2>/dev/null && echo "stopped $s" || echo "$s not running"
+done
+
+# Advanced scenario harnesses
+for s in harness harness-a harness-b harness-c; do
+  screen -S $s -X quit 2>/dev/null
+done
 ```
-Look for lines like:
+
+### Stop relay
+
+```bash
+screen -S relay -X quit 2>/dev/null && echo "relay stopped"
 ```
-DEBUG sprout_relay: authenticated pubkey=<hex>
-DEBUG sprout_relay: EVENT accepted kind=40001 channel=<id>
-DEBUG sprout_relay: delivered to 2 subscriber(s)
+
+### Verify all sessions gone
+
+```bash
+screen -ls
+# Should show "No Sockets found" or only unrelated sessions
 ```
 
-**Query the database for messages:**
-```sql
-SELECT
-    HEX(channel_id) AS channel,
-    content,
-    created_at
-FROM events
-WHERE channel_id = (SELECT id FROM channels WHERE name = 'agent-test')
-ORDER BY created_at DESC
-LIMIT 20;
+### Tear down Docker services
+
+```bash
+# Stop services and remove volumes (full reset)
+docker compose down -v
+
+# Stop services only (preserve data for next run)
+docker compose down
 ```
 
-**Read channel history via MCP** (from within a goose session with sprout-mcp loaded):
+### Clean up temp files
+
+```bash
+rm -f /tmp/agent-alice.log /tmp/agent-bob.log /tmp/agent-charlie.log
+rm -f /tmp/harness.log /tmp/harness-a.log /tmp/harness-b.log /tmp/harness-c.log
+rm -f /tmp/sprout-relay.log
+rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt
 ```
-Use the sprout MCP tool to list messages in the agent-test channel.
+
+---
+
+## 8. Known Issues / Troubleshooting
+
+### Current Status
+
+All automated tests pass as of 2026-03-10:
+
+- ✅ 20/20 REST API integration tests
+- ✅ 13/13 WebSocket relay integration tests
+- ✅ 7/7 MCP server integration tests
+- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness
+
+---
+
+### Harness exits immediately with "configuration error"
+
+**Cause:** `SPROUT_PRIVATE_KEY` not set or invalid.
+
+```bash
+echo $SPROUT_PRIVATE_KEY
+# Must be a valid nsec1... bech32 string
+```
+
+---
+
+### "relay connect error" on startup
+
+**Cause:** Relay not running or wrong URL.
+
+```bash
+# Check relay session
+screen -ls | grep relay
+
+# If missing, start it
+screen -dmS relay bash -c \
+  'export $(cat .env | grep -v "^#" | grep -v "^$" | xargs) 2>/dev/null; \
+   ./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log'
+
+# Verify
+curl -s http://localhost:3000/health
 ```
 
 ---
 
-## 9. Running the Test Suite
+### "discovered 0 channel(s)"
 
-### Unit tests (no infrastructure required)
+**Cause:** Agent pubkey is not a member of any open channels.
 
 ```bash
-just test-unit
-# or equivalently:
-./scripts/run-tests.sh unit
+# Check what channels are accessible
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels" | jq 'length'
 ```
 
-Runs `sprout-core` and `sprout-auth` unit tests. No Docker needed.
+Open channels are accessible to any authenticated pubkey. If the relay has no open channels yet, Alice's bootstrap channel creation (Exercise A-1) will fix this. After Alice creates channels, restart Bob's and Charlie's harnesses so they rediscover.
+
+---
+
+### "failed to spawn agent"
+
+**Cause:** `goose` binary not found or not on `$PATH`.
+
+```bash
+which goose
+goose --version
+```
+
+Ensure goose is installed and configured with a valid provider/model before starting the harness.
+
+---
+
+### Agent hangs, turn never completes
 
-### Integration tests (requires running services)
+**Cause:** `GOOSE_MODE=auto` not set — goose is waiting for permission approval.
 
 ```bash
-just test-integration
-# or equivalently:
-./scripts/run-tests.sh integration
+# Kill and restart with GOOSE_MODE=auto
+screen -S agent-alice -X quit
+SPROUT_PRIVATE_KEY="$ALICE_NSEC" \
+SPROUT_RELAY_URL="ws://localhost:3000" \
+GOOSE_MODE=auto \
+  screen -dmS agent-alice bash -c 'sprout-acp 2>&1 | tee /tmp/agent-alice.log'
 ```
 
-Starts services if not running, applies migrations, then tests `sprout-db` and `sprout-auth` integration.
+`GOOSE_MODE=auto` is **mandatory** for all harness instances. Without it, the first MCP tool call will hang indefinitely.
+
+---
+
+### No agent reply after @mention
+
+Checklist:
 
-### All tests
+1. Is the harness running? `pgrep -a sprout-acp`
+2. Is the harness subscribed to the target channel? `grep "subscribed" /tmp/agent-alice.log`
+3. Did `mention` use the correct pubkey hex? Check `$ALICE_PUBKEY` is set correctly.
+4. Check harness logs for errors: `tail -50 /tmp/agent-alice.log`
+5. Verify via REST API that the @mention event arrived:
 
 ```bash
-just test
+curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels/$GENERAL/messages?limit=10" \
+  | jq '.[] | {kind: .kind, sender: .pubkey[:16], body: .content[:100]}'
 ```
 
-### E2E relay tests (requires running relay)
+---
 
-The e2e tests in `crates/sprout-test-client/tests/e2e_relay.rs` are marked `#[ignore]` by default. Run them explicitly with a live relay:
+### MCP tool calls failing
+
+**Cause:** `sprout-mcp-server` binary not found, or wrong relay URL passed to MCP.
 
 ```bash
-# Relay must be running (step 4)
-cargo test --test e2e_relay -- --ignored --nocapture
+which sprout-mcp-server
+# If missing: cargo build --release -p sprout-mcp-server
+# Then: export PATH="$PWD/target/release:$PATH"
+```
+
+---
+
+### Channel UUIDs not set after Alice's first exercise
 
-# Override relay URL if not on default port:
-RELAY_URL=ws://localhost:3001 cargo test --test e2e_relay -- --ignored --nocapture
+If `$GENERAL`, `$ALICE_TESTING`, or `$PRIVATE_OPS` are empty, Alice may not have finished yet. Wait longer, then re-query:
+
+```bash
+sleep 30
+export GENERAL=$(curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
+  "http://localhost:3000/api/channels" \
+  | jq -r '.[] | select(.name == "general") | .id')
+echo "GENERAL=$GENERAL"
 ```
 
-Key e2e tests:
-- `test_connect_and_authenticate` — NIP-42 auth handshake
-- `test_send_event_and_receive_via_subscription` — pub/sub round-trip
-- `test_multiple_concurrent_clients` — 3 clients, 1 sender, all receive
-- `test_unauthenticated_rejected` — auth enforcement
-- `test_pubkey_mismatch_rejected` — impersonation prevention
+If still empty, check Alice's harness logs for errors: `tail -50 /tmp/agent-alice.log`.
 
 ---
 
-## 10. Troubleshooting
-
-| Symptom | Likely Cause | Fix |
-|---------|-------------|-----|
-| `Connection refused` on port 3000 | Relay not running | `screen -r sprout-relay` to check; restart with `screen -dmS sprout-relay just relay` |
-| Port 3000 already in use | Another process (Node, etc.) | Set `SPROUT_BIND_ADDR=0.0.0.0:3001` and `RELAY_URL=ws://localhost:3001` in `.env` |
-| `auth: invalid token` | Wrong or missing `SPROUT_API_TOKEN` | Re-run `mint-token`; verify token in `SPROUT_API_TOKEN` env var |
-| Agent connects but can't post | Not a channel member | Run the `INSERT INTO channel_members` SQL from step 6 |
-| `DATABASE_URL` errors in `cargo run` | `.env` not loaded | Use `just relay` instead of `cargo run` directly, or `export $(cat .env | xargs)` |
-| MySQL unhealthy after `docker compose up` | Slow start | Wait 30s; check `docker compose logs mysql` for errors |
-| `sprout-mcp` generates ephemeral key | `SPROUT_PRIVATE_KEY` not set | Set `SPROUT_PRIVATE_KEY=nsec1...` so the agent's identity persists across restarts |
-| E2e tests time out | Relay not running or wrong URL | Check `RELAY_URL` env var; confirm relay is listening with `curl http://localhost:3000/info` |
-| `SQLX_OFFLINE` errors in CI | Missing `.sqlx/` query cache | Run `cargo sqlx prepare --workspace` locally and commit the `.sqlx/` directory |
+### Stale events replayed on harness restart
+
+**Expected behavior.** On startup, the harness replays all unprocessed `@mentions` since the last run. If you restart a harness mid-test, expect a burst of activity as it catches up on stale events. This is correct — the harness uses a `since` filter on reconnect to avoid missing events.
+
+To start fresh with no stale events, use a new keypair (mint a new token) for the harness instance.
+
+---
+
+### Docker services unhealthy
+
+```bash
+docker compose ps
+# If any service is not "Up":
+docker compose down -v && docker compose up -d
+# Wait 30s then re-run migrations:
+sqlx migrate run --database-url "$DATABASE_URL"
+```
diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md
index d20be11c19..02478d2ed3 100644
--- a/crates/sprout-acp/README.md
+++ b/crates/sprout-acp/README.md
@@ -134,7 +134,7 @@ Set `SPROUT_ACP_AGENT_COMMAND` and `SPROUT_ACP_AGENT_ARGS` to point at your agen
 
 ## Testing
 
-See [TESTING.md](TESTING.md) for the full integration testing guide — 9 test scenarios, verification commands, troubleshooting, and CI integration notes.
+See the [root TESTING.md](../../TESTING.md) for the full integration testing guide — automated test suites, multi-agent E2E testing via the ACP harness, and troubleshooting.
 
 ## License
 
diff --git a/crates/sprout-acp/TESTING.md b/crates/sprout-acp/TESTING.md
deleted file mode 100644
index e779afba8b..0000000000
--- a/crates/sprout-acp/TESTING.md
+++ /dev/null
@@ -1,986 +0,0 @@
-# sprout-acp Integration Testing Guide
-
-## Overview
-
-`sprout-acp` is the ACP harness that bridges Sprout relay events to AI agents. It:
-
-- Connects to a Sprout relay via NIP-01 WebSocket with NIP-42 auth
-- Listens for `@mention` events (kind 40001) targeting the agent's pubkey
-- Queues events per channel, enforcing one prompt-in-flight globally
-- Sends batched prompts to an ACP agent over stdio JSON-RPC 2.0
-- The agent uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to respond
-
-**Architecture:**
-
-```
-Sprout Relay ──WS (NIP-01)──→ sprout-acp harness ──stdio (JSON-RPC)──→ ACP Agent
-                                                                          │
-                                                                     Sprout MCP
-                                                                    (16 tools)
-```
-
-This document covers manual integration testing of the full pipeline. For unit tests, run `cargo test` in the crate root.
-
----
-
-## Prerequisites
-
-### 1. Docker Services
-
-```bash
-cd /path/to/sprout2
-docker compose up -d
-```
-
-Required services: MySQL, Redis, Typesense, Keycloak. Verify:
-
-```bash
-docker compose ps
-```
-
-All services should be `Up`.
-
-### 2. Relay
-
-Start `sprout-relay` in a detached screen session:
-
-```bash
-screen -dmS relay just relay
-```
-
-Verify it's listening:
-
-```bash
-screen -r relay   # Ctrl-A D to detach
-# or:
-curl -s http://localhost:3000/health || echo "relay not up"
-```
-
-### 3. Build Binaries
-
-```bash
-cargo build --release -p sprout-acp -p sprout-mcp-server
-```
-
-Ensure `target/release/sprout-acp`, `target/release/sprout-mcp-server`, and `target/release/mention` are present:
-
-```bash
-ls -la target/release/sprout-acp target/release/sprout-mcp-server target/release/mention
-```
-
-Add to PATH for convenience:
-
-```bash
-export PATH="$PWD/target/release:$PATH"
-```
-
-### 4. Test Keys
-
-These keys are for local development only — never use in production.
-
-```bash
-export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
-export AGENT_PUBKEY_HEX=ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb
-export AGENTS_CHANNEL=94a444a4-c0a3-5966-ab05-530c6ddc2301
-```
-
-**Generating additional keypairs:** Use `sprout-admin mint-token` to create new keypairs for additional agents. Each call prints an `nsec` private key and API token — save them immediately.
-
-```bash
-cargo run -p sprout-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read"
-```
-
-**Channels:** Open channels (the default for local dev) are accessible to any authenticated pubkey — no extra setup needed. See the [README](README.md#channels) for details.
-
-### 5. MySQL Access
-
-```bash
-# Connect via docker exec (mysql client may not be installed locally)
-docker exec -it sprout-mysql mysql -u sprout -psprout_dev sprout
-```
-
----
-
-## Quick Start (5 Minutes)
-
-Minimal path: run harness with goose, send a test `@mention`, verify reply.
-
-### Step 1: Set Environment
-
-```bash
-export PATH="$PWD/target/release:$PATH"
-export SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm
-export SPROUT_RELAY_URL=ws://localhost:3000
-export GOOSE_MODE=auto
-```
-
-### Step 2: Start Harness
-
-```bash
-screen -dmS harness sprout-acp
-screen -r harness   # Ctrl-A D to detach
-```
-
-Expected log output:
-```
-sprout-acp starting: relay=ws://localhost:3000 harness_pubkey=... agent_pubkey=ae670a...
-agent initialized: ...
-connected to relay at ws://localhost:3000
-discovered N channel(s)
-subscribed to channel 94a444a4-c0a3-5966-ab05-530c6ddc2301
-```
-
-### Step 3: Send a Test @mention
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb "Hello agent, reply with 'pong'"
-```
-
-### Step 4: Verify Reply
-
-Check harness logs:
-
-```bash
-screen -r harness
-# Look for: "turn complete for channel 94a444a4-...: end_turn"
-```
-
-Check MySQL for agent reply:
-
-```sql
-SELECT HEX(e.pubkey) as sender, e.kind, SUBSTRING(e.content, 1, 200) as body, e.created_at
-FROM events e
-WHERE e.kind = 40001
-ORDER BY e.created_at DESC LIMIT 10;
-```
-
-The agent's pubkey (`ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb`) should appear as `sender` with a reply body.
-
----
-
-## Agent Setup
-
-### goose (Native ACP)
-
-goose supports ACP natively. No adapter needed.
-
-```bash
-export SPROUT_ACP_AGENT_COMMAND=goose
-export SPROUT_ACP_AGENT_ARGS=acp
-export GOOSE_MODE=auto   # Disables interactive permission prompts
-```
-
-**Gotchas:**
-- `GOOSE_MODE=auto` is required — without it, goose may pause waiting for user approval on tool calls
-- Ensure `goose` is on `$PATH` and configured with a valid provider/model
-
-Verify goose is working standalone:
-
-```bash
-echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | goose acp
-```
-
----
-
-### codex (via codex-acp adapter)
-
-codex-acp is Zed's ACP wrapper around OpenAI Codex. Requires building from source.
-
-**Build:**
-
-```bash
-# Requires Rust 1.91+ — use hermit if needed
-cd /path/to/codex-acp
-cargo build --release
-# Binary at: /path/to/codex-acp/target/release/codex-acp
-```
-
-**Configure:**
-
-```bash
-export OPENAI_API_KEY=sk-...   # or CODEX_API_KEY
-export SPROUT_ACP_AGENT_COMMAND=/path/to/codex-acp/target/release/codex-acp
-export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
-```
-
-The `-c permissions.approval_policy="never"` arg disables codex's approval prompts (equivalent to goose's `GOOSE_MODE=auto`).
-
-**Gotchas:**
-- Use the full absolute path for `SPROUT_ACP_AGENT_COMMAND` — codex-acp is not typically on `$PATH`
-- `SPROUT_ACP_AGENT_ARGS` is comma-separated; the config parser splits on `,`
-- Repo: https://github.com/zed-industries/codex-acp
-
----
-
-### claude code (via claude-agent-acp adapter)
-
-claude-agent-acp is Zed's ACP wrapper around the Claude Agent SDK. Requires Node.js.
-
-**Build:**
-
-```bash
-cd /path/to/claude-agent-acp
-npm install
-npm run build
-# Entry point at: /path/to/claude-agent-acp/dist/index.js
-```
-
-**Configure:**
-
-```bash
-export ANTHROPIC_API_KEY=sk-ant-...
-export SPROUT_ACP_AGENT_COMMAND=node
-export SPROUT_ACP_AGENT_ARGS=/path/to/claude-agent-acp/dist/index.js
-```
-
-**Gotchas:**
-- `node` must be on `$PATH`
-- The `dist/index.js` path must be absolute
-- Repo: https://github.com/zed-industries/claude-agent-acp
-
----
-
-## Test Scenarios
-
-### Scenario A: Basic @mention → Agent Replies
-
-**Description:** Single @mention event triggers a prompt; agent replies via `send_message`.
-
-**Steps:**
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "What is 2 + 2?"
-```
-
-**Expected behavior:**
-- Harness logs: `prompting agent for channel 94a444a4-... (session ..., 1 event(s))`
-- Agent calls `send_message` MCP tool with a reply
-- Harness logs: `turn complete for channel 94a444a4-...: end_turn`
-
-**Verify:**
-
-```sql
-SELECT HEX(e.pubkey) as sender, SUBSTRING(e.content, 1, 200) as body, e.created_at
-FROM events e
-WHERE e.kind = 40001
-  AND HEX(e.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb'
-ORDER BY e.created_at DESC LIMIT 5;
-```
-
----
-
-### Scenario B: Multi-Event Batch (Rapid @mentions)
-
-**Description:** Three @mentions sent in rapid succession should be batched into a single prompt (queue drains all pending events for a channel at once).
-
-**Steps:**
-
-```bash
-for i in 1 2 3; do
-  mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-    ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-    "Batch message $i" &
-done
-wait
-```
-
-**Expected behavior:**
-- Harness logs show `(session ..., 3 event(s))` or `(2 event(s))` + `(1 event(s))` depending on timing
-- Only **one** `session/prompt` call fires per flush cycle
-- Agent receives all messages in a single prompt text
-
-**Verify:**
-
-Check harness log for batch size:
-
-```bash
-screen -r harness
-# Look for: "prompting agent for channel ... (session ..., N event(s))"
-```
-
-If N > 1, batching worked. If you see three separate `prompting agent` lines, the events arrived too slowly — try sending them faster or add a brief sleep before the harness starts.
-
----
-
-### Scenario C: Agent Crash Recovery
-
-**Description:** Kill the agent subprocess; harness should detect exit, respawn, and process the next event.
-
-**Steps:**
-
-1. While harness is running, find the agent PID:
-
-```bash
-# In a separate terminal
-pgrep -f "goose acp"   # or codex-acp, node, etc.
-```
-
-2. Kill it:
-
-```bash
-kill -9 <agent-pid>
-```
-
-3. Send a new @mention:
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "Are you still alive?"
-```
-
-**Expected behavior:**
-- Harness logs: `agent process exited — respawning`
-- Harness logs: `agent respawned successfully`
-- Harness logs: `agent initialized: ...`
-- The new @mention is processed and agent replies
-
-**Verify:**
-
-```bash
-screen -r harness
-# Sequence: "agent process exited" → "agent respawned successfully" → "turn complete"
-```
-
-**Note:** Events in-flight when the agent crashes are requeued — they will be retried after respawn.
-
----
-
-### Scenario D: Relay Disconnect Recovery
-
-**Description:** Kill the relay; harness should detect disconnect and reconnect automatically.
-
-**Steps:**
-
-1. Stop the relay:
-
-```bash
-screen -r relay
-# Ctrl-C to stop, then Ctrl-A D to detach
-```
-
-2. Watch harness logs:
-
-```bash
-screen -r harness
-# Look for: "relay connection lost — reconnecting"
-```
-
-3. Restart the relay:
-
-```bash
-screen -dmS relay just relay
-```
-
-**Expected behavior:**
-- Harness logs: `relay connection lost — reconnecting`
-- Harness logs: `relay reconnect failed: ... — retrying in 5s` (repeats until relay is back)
-- After relay restarts: `relay reconnected`
-- Harness re-subscribes to channels and resumes normal operation
-
-4. Verify recovery by sending a new @mention:
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "Post-reconnect test"
-```
-
-**Verify:**
-
-```bash
-screen -r harness
-# Look for: "relay reconnected" followed by "turn complete"
-```
-
----
-
-### Scenario E: Turn Timeout
-
-**Description:** Set a very short timeout; send a prompt that will take longer than the timeout. Harness should cancel the turn and log a warning.
-
-**Steps:**
-
-1. Restart harness with short timeout:
-
-```bash
-screen -S harness -X quit
-SPROUT_ACP_TURN_TIMEOUT=5 \
-SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
-SPROUT_ACP_AGENT_COMMAND=goose \
-SPROUT_ACP_AGENT_ARGS=acp \
-GOOSE_MODE=auto \
-screen -dmS harness sprout-acp
-```
-
-2. Send a prompt that requires significant computation:
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "Write a detailed 500-word essay on the history of computing, then list 50 prime numbers"
-```
-
-**Expected behavior:**
-- After ~5 seconds: `turn timeout (5s) for channel 94a444a4-... — cancelling`
-- Harness sends `session/cancel` to agent
-- Harness logs: `turn cancelled for channel 94a444a4-...`
-- Harness continues running and accepts new events
-
-**Verify:**
-
-```bash
-screen -r harness
-# Look for: "turn timeout (5s) for channel ... — cancelling"
-# Then: "turn cancelled for channel ..."
-```
-
-**Note:** Timed-out events are NOT requeued — the turn was attempted. Reset timeout to 300 after this test.
-
----
-
-### Scenario F: Permission Handling (Auto-Approve)
-
-**Description:** Verify that `GOOSE_MODE=auto` prevents the agent from pausing for permission prompts on tool calls.
-
-**Steps:**
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "Use your tools to get the last 5 messages from this channel and summarize them"
-```
-
-**Expected behavior:**
-- Agent calls `get_channel_history` MCP tool without pausing
-- No permission prompts appear in harness stdout
-- Turn completes with `end_turn`
-
-**Verify:**
-
-```bash
-screen -r harness
-# Should NOT see any "waiting for approval" or "permission" lines
-# Should see: "turn complete for channel ...: end_turn"
-```
-
-If the agent hangs indefinitely, `GOOSE_MODE=auto` may not be set. Kill the harness, set `GOOSE_MODE=auto`, and restart.
-
----
-
-### Scenario G: Channel Discovery
-
-**Description:** Verify the harness discovers and subscribes to the agent's channels on startup.
-
-**Steps:**
-
-1. Start harness fresh (no prior subscriptions):
-
-```bash
-screen -S harness -X quit
-screen -dmS harness sprout-acp
-screen -r harness
-```
-
-**Expected behavior:**
-
-```
-discovered N channel(s)
-subscribed to channel 94a444a4-c0a3-5966-ab05-530c6ddc2301
-```
-
-**Verify:**
-
-The harness calls the relay's REST API to discover channels associated with the agent's pubkey (`ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb`). If `discovered 0 channel(s)` appears, the agent may not be a member of any channels — check the Sprout admin UI or database.
-
-```sql
--- Check agent channel membership
-SELECT HEX(c.id) as channel_id, c.name
-
-
-
-FROM channels c JOIN channel_members cm ON cm.channel_id = c.id
-WHERE HEX(cm.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
-```
-
----
-
-### Scenario H: Concurrent Channels (FIFO Fairness)
-
-**Description:** Send @mentions to two different channels; verify the harness processes them in FIFO order (oldest pending event first) with one prompt in flight at a time.
-
-**Setup:** You need a second channel UUID. Create one in the Sprout admin UI or use an existing one. Replace `<CHANNEL_B_UUID>` below.
-
-**Steps:**
-
-```bash
-# Send to channel A first
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "Channel A message"
-
-# Immediately send to channel B
-mention <CHANNEL_B_UUID> \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "Channel B message"
-```
-
-**Expected behavior:**
-- Channel A prompt fires first (oldest pending event)
-- Channel B prompt fires after Channel A's turn completes
-- No overlap — only one `session/prompt` in flight at a time
-
-**Verify:**
-
-```bash
-screen -r harness
-# Look for sequential "prompting agent for channel ..." lines
-# Channel A should appear before Channel B
-# No two "prompting agent" lines without a "turn complete" between them
-```
-
----
-
-### Scenario I: Multi-Agent (3 Agents, 1 Channel)
-
-**Description:** Run three harness instances (goose, codex, claude) simultaneously, all subscribed to the same channel. Send an @mention to one; verify all three see the event and respond.
-
-**Setup:** Each agent needs its own keypair. Mint them with `sprout-admin`:
-
-```bash
-# Agent 1 (goose) — use the test key from Prerequisites, or mint a fresh one
-# Agent 2 (codex)
-cargo run -p sprout-admin -- mint-token --name "agent-codex" --scopes "messages:read,messages:write,channels:read"
-
-# Agent 3 (claude)
-cargo run -p sprout-admin -- mint-token --name "agent-claude" --scopes "messages:read,messages:write,channels:read"
-```
-
-Save the `nsec` keys and pubkey hex values from the output.
-
-**Steps:**
-
-1. Start three harness instances in separate screen sessions:
-
-```bash
-# Goose
-SPROUT_PRIVATE_KEY=<goose-nsec> \
-GOOSE_MODE=auto \
-screen -dmS harness-goose sprout-acp
-
-# Codex
-OPENAI_API_KEY=$(cat ~/keys/openai.key) \
-SPROUT_PRIVATE_KEY=<codex-nsec> \
-SPROUT_ACP_AGENT_COMMAND=/path/to/codex-acp/target/release/codex-acp \
-SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"' \
-screen -dmS harness-codex sprout-acp
-
-# Claude
-ANTHROPIC_API_KEY=$(cat ~/keys/anthropic.key) \
-SPROUT_PRIVATE_KEY=<claude-nsec> \
-SPROUT_ACP_AGENT_COMMAND=node \
-SPROUT_ACP_AGENT_ARGS=/path/to/claude-agent-acp/dist/index.js \
-screen -dmS harness-claude sprout-acp
-```
-
-2. Verify all three are subscribed:
-
-```bash
-for s in harness-goose harness-codex harness-claude; do
-  echo "=== $s ==="; screen -S $s -X hardcopy /tmp/$s.txt; grep -c "subscribed" /tmp/$s.txt
-done
-```
-
-3. Send @mentions to each agent:
-
-```bash
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 <goose-agent-pubkey-hex> "Hello goose, reply PONG"
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 <codex-agent-pubkey-hex> "Hello codex, reply PONG"
-mention 94a444a4-c0a3-5966-ab05-530c6ddc2301 <claude-agent-pubkey-hex> "Hello claude, reply PONG"
-```
-
-**Expected behavior:**
-- Each harness picks up only the @mention targeting its agent pubkey
-- Each agent replies via `send_message`
-- All three replies appear in the DB with different sender pubkeys
-
-**Verify:**
-
-```bash
-docker exec sprout-mysql mysql -u sprout -psprout_dev sprout -e "
-SELECT SUBSTRING(HEX(pubkey), 1, 16) as sender, SUBSTRING(content, 1, 100) as body, created_at
-FROM events WHERE kind = 40001 ORDER BY created_at DESC LIMIT 10;"
-```
-
-Look for three distinct sender pubkey prefixes, each with a PONG reply.
-
-**Cleanup:**
-
-```bash
-for s in harness-goose harness-codex harness-claude; do screen -S $s -X quit; done
-```
-
----
-
-## Verification Commands
-
-### Harness Log Patterns
-
-```bash
-# Attach to harness screen session
-screen -r harness
-
-# Grep harness log (if redirected to file)
-grep "turn complete" /tmp/harness.log
-grep "turn timeout" /tmp/harness.log
-grep "agent process exited" /tmp/harness.log
-grep "relay connection lost" /tmp/harness.log
-```
-
-To capture harness output to a file:
-
-```bash
-screen -S harness -X quit
-screen -dmS harness bash -c 'sprout-acp 2>&1 | tee /tmp/harness.log'
-```
-
-### Database Queries
-
-```sql
--- All recent events (last 10)
-SELECT HEX(e.pubkey) as sender, e.kind,
-       SUBSTRING(e.content, 1, 200) as body,
-       e.created_at
-FROM events e
-ORDER BY e.created_at DESC LIMIT 10;
-
--- Agent reply events only
-SELECT HEX(e.pubkey) as sender, e.kind,
-       SUBSTRING(e.content, 1, 200) as body,
-       e.created_at
-FROM events e
-WHERE e.kind = 40001
-  AND HEX(e.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb'
-ORDER BY e.created_at DESC LIMIT 10;
-
--- Events in a specific channel
-SELECT HEX(e.pubkey) as sender,
-       SUBSTRING(e.content, 1, 200) as body,
-       e.created_at
-FROM events e
-JOIN event_tags et ON et.event_id = e.id
-WHERE et.tag_name = 'channel'
-  AND et.tag_value = '94a444a4-c0a3-5966-ab05-530c6ddc2301'
-ORDER BY e.created_at DESC LIMIT 20;
-
--- Agent channel membership
-SELECT c.uuid, c.name
-FROM channels c
-JOIN channel_members cm ON cm.channel_id = c.id
-
-WHERE HEX(cm.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
-```
-
-### Relay Health
-
-```bash
-# Check relay is accepting WebSocket connections
-curl -i -N -H "Connection: Upgrade" \
-  -H "Upgrade: websocket" \
-  -H "Sec-WebSocket-Version: 13" \
-  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-  http://localhost:3000/
-
-# Check relay HTTP health (if endpoint exists)
-curl -s http://localhost:3000/health
-```
-
-### Process Status
-
-```bash
-# Check harness is running
-pgrep -a sprout-acp
-
-# Check agent subprocess
-pgrep -a goose       # for goose
-pgrep -a codex-acp   # for codex
-pgrep -fa "node.*claude-agent-acp"  # for claude code
-
-# Check all screen sessions
-screen -ls
-```
-
----
-
-## Troubleshooting
-
-### Harness exits immediately with "configuration error"
-
-**Cause:** Missing required env vars.
-
-**Fix:** Ensure `SPROUT_PRIVATE_KEY` is set:
-
-```bash
-echo $SPROUT_PRIVATE_KEY
-```
-
-Both must be valid `nsec1...` bech32 strings.
-
----
-
-### "relay connect error" on startup
-
-**Cause:** Relay not running or wrong URL.
-
-**Fix:**
-
-```bash
-screen -ls | grep relay
-# If no relay session: screen -dmS relay just relay
-# Check URL: echo $SPROUT_RELAY_URL (default: ws://localhost:3000)
-```
-
----
-
-### "channel discovery error" on startup
-
-**Cause:** Relay REST API unreachable, or agent pubkey not registered.
-
-**Fix:**
-
-```bash
-# Verify relay is up
-curl -s http://localhost:3000/health
-
-# Check agent is in at least one channel (see DB query in Scenario G)
-```
-
----
-
-### "failed to spawn agent"
-
-**Cause:** Agent binary not found or not executable.
-
-**Fix:**
-
-```bash
-which goose          # for goose
-ls -la $SPROUT_ACP_AGENT_COMMAND  # for codex-acp or node path
-```
-
-Ensure the binary is on `$PATH` or the full path is correct.
-
----
-
-### Agent hangs, turn never completes
-
-**Cause:** Agent waiting for permission approval (`GOOSE_MODE` not set, or codex approval policy not configured).
-
-**Fix:**
-
-```bash
-# For goose:
-export GOOSE_MODE=auto
-
-# For codex:
-export SPROUT_ACP_AGENT_ARGS='-c,permissions.approval_policy="never"'
-```
-
-Restart harness after setting.
-
----
-
-### "discovered 0 channel(s)"
-
-**Cause:** Agent pubkey is not a member of any channels on the relay.
-
-**Fix:** Add the agent to a channel via the Sprout admin UI, or check the DB:
-
-```sql
-SELECT c.uuid, c.name
-FROM channels c
-JOIN channel_members cm ON cm.channel_id = c.id
-
-WHERE HEX(cm.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb';
-```
-
----
-
-### No agent reply in DB after @mention
-
-**Checklist:**
-
-1. Is the harness running? `pgrep -a sprout-acp`
-2. Is the harness subscribed to the channel? Check startup logs for `subscribed to channel 94a444a4-...`
-3. Did the `mention` command use the correct pubkey hex? (`ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb`)
-4. Check harness logs for errors: `screen -r harness`
-5. Did the agent have the MCP tools configured? Look for `sprout-mcp` in agent init logs.
-
----
-
-### MCP tool calls failing
-
-**Cause:** `sprout-mcp-server` binary not found, or wrong relay URL/keys passed to MCP.
-
-**Fix:**
-
-```bash
-# Verify MCP binary
-which sprout-mcp-server
-# or check SPROUT_ACP_MCP_COMMAND
-echo $SPROUT_ACP_MCP_COMMAND
-
-# Test MCP server directly
-SPROUT_RELAY_URL=ws://localhost:3000 \
-SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
-sprout-mcp-server
-```
-
----
-
-### codex-acp build fails
-
-**Cause:** Rust version too old (requires 1.91+).
-
-**Fix:**
-
-```bash
-# Using hermit (if available in repo)
-cd /path/to/codex-acp
-bin/hermit env
-cargo build --release
-```
-
----
-
-## Test Results (2026-03-10)
-
-All three agents tested end-to-end against a local relay with Docker services running.
-
-| Agent | Adapter | Command | Test Prompt | Reply | Result |
-|-------|---------|---------|-------------|-------|--------|
-| **goose** | Native ACP (`goose acp`) | `goose acp` | "What is the capital of France? Reply with just the city name." | `Paris` | ✅ PASS |
-| **codex** | codex-acp (Zed adapter) | `codex-acp -c permissions.approval_policy="never"` | "What is 7 times 8? Reply with just the number." | `56` | ✅ PASS |
-| **claude code** | claude-agent-acp (Zed adapter) | `node dist/index.js` | "What is the square root of 144? Reply with just the number." | `12` | ✅ PASS |
-
-### Observations
-
-- **Session creation**: All agents created sessions in <2s. goose and claude used string UUIDs; codex used a UUID with timestamp prefix.
-- **Turn latency**: goose ~4s, codex ~6s, claude ~3s for simple math/fact prompts.
-- **MCP tool usage**: All agents successfully called `send_message` via the Sprout MCP server to post replies.
-- **Stale event handling**: On startup, each harness instance picked up and processed stale @mention events from prior test runs. All handled gracefully.
-- **Adapter build requirements**:
-  - codex-acp requires Rust 1.91+ (use hermit: `REPOS/sprout2/bin/cargo`)
-  - claude-agent-acp requires Node.js (`npm install && npm run build`)
-
-### DB Verification Query Used
-
-```bash
-docker exec sprout-mysql mysql -u sprout -psprout_dev sprout -e "
-SELECT
-    SUBSTRING(HEX(pubkey), 1, 16) as sender_prefix,
-    kind,
-    SUBSTRING(content, 1, 120) as body,
-    created_at
-FROM events
-WHERE kind = 40001
-ORDER BY created_at DESC
-LIMIT 10;
-"
-```
-
----
-
-## CI Integration (Aspirational)
-
-The test scenarios above are currently manual. This section outlines how to automate them.
-
-### Recommended Approach
-
-A CI integration test suite would:
-
-1. **Spin up Docker services** — `docker compose up -d` with health checks
-2. **Build all binaries** — `cargo build --release`
-3. **Start relay** — as a background process with stdout captured
-4. **Start harness** — with test keys, stdout captured to a log file
-5. **Send @mention events** — using the `mention` binary
-6. **Assert outcomes** — poll MySQL for expected reply events (with timeout)
-7. **Tear down** — kill harness, relay, `docker compose down`
-
-### Test Harness Sketch
-
-```bash
-#!/usr/bin/env bash
-# ci-integration-test.sh (aspirational)
-set -euo pipefail
-
-TIMEOUT=30
-RELAY_PID=""
-HARNESS_PID=""
-
-cleanup() {
-  [[ -n "$HARNESS_PID" ]] && kill "$HARNESS_PID" 2>/dev/null || true
-  [[ -n "$RELAY_PID" ]] && kill "$RELAY_PID" 2>/dev/null || true
-}
-trap cleanup EXIT
-
-# Start relay
-just relay &
-RELAY_PID=$!
-sleep 3
-
-# Start harness
-SPROUT_PRIVATE_KEY=nsec1ddyp0fufd6ejerfqkxcfqlmkktwzx7w45emalvgtcvyafefusj5q8fyllm \
-SPROUT_ACP_AGENT_ARGS=acp \
-GOOSE_MODE=auto \
-./target/release/sprout-acp > /tmp/harness.log 2>&1 &
-HARNESS_PID=$!
-sleep 2
-
-# Send test mention
-./target/release/mention \
-  94a444a4-c0a3-5966-ab05-530c6ddc2301 \
-  ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb \
-  "CI test: reply with OK"
-
-# Poll for reply
-START=$(date +%s)
-while true; do
-  REPLY=$(mysql -h 127.0.0.1 -u root -proot sprout -sNe \
-    "SELECT SUBSTRING(e.content, 1, 200) FROM events e
-     WHERE e.kind = 40001
-       AND HEX(e.pubkey) = 'ae670a075ac2446f445808ab5a1a796cec37c72c70b25e10ee39f7f0eab50feb'
-     ORDER BY e.created_at DESC LIMIT 1;" 2>/dev/null || true)
-  if [[ -n "$REPLY" ]]; then
-    echo "✅ Agent replied: $REPLY"
-    exit 0
-  fi
-  if (( $(date +%s) - START > TIMEOUT )); then
-    echo "❌ Timeout waiting for agent reply"
-    cat /tmp/harness.log
-    exit 1
-  fi
-  sleep 1
-done
-```
-
-### Blocking Issues for Full CI Automation
-
-- **Agent API keys** — goose, codex, and claude code all require live API keys; CI needs secrets management
-- **Relay startup time** — relay may need a longer warm-up than a fixed `sleep`; add a health-check poll
-- **Non-deterministic agent responses** — assertions should check for _any_ reply, not specific content
-- **Parallel test isolation** — each test run needs unique channel UUIDs to avoid cross-test contamination
-- **`mention` binary** — must be built as part of CI setup
-
-### Near-Term Automation Targets
-
-The highest-value scenarios to automate first (no live API keys needed if using a mock agent):
-
-1. **Scenario A** (basic flow) — core smoke test
-2. **Scenario C** (crash recovery) — critical reliability test
-3. **Scenario D** (relay reconnect) — critical reliability test
-4. **Scenario E** (turn timeout) — verifiable without agent intelligence
-
-Scenarios B, F, G, H can follow once the core pipeline is stable.
diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs
index 9dae5964fd..2a981fb255 100644
--- a/crates/sprout-acp/src/relay.rs
+++ b/crates/sprout-acp/src/relay.rs
@@ -654,7 +654,7 @@ async fn send_subscribe(
             KIND_WORKFLOW_APPROVAL_REQUESTED,
             KIND_STREAM_REMINDER,
         ],
-        "#e": [channel_id.to_string()],
+        "#h": [channel_id.to_string()],
         "#p": [agent_pubkey_hex],
     });
 
diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs
index 3740cdc974..d6eaeb43dc 100644
--- a/crates/sprout-mcp/src/lib.rs
+++ b/crates/sprout-mcp/src/lib.rs
@@ -51,7 +51,7 @@
 //!
 //! ### Channels
 //! - **`list_channels`** — List channels accessible to this agent, optionally filtered by
-//!   visibility (`public` / `private`).
+//!   visibility (`open` / `private`).
 //! - **`create_channel`** — Create a new channel with a given name, type, and visibility.
 //!
 //! ### Canvas
diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs
index 6d3b553833..3ab3e46651 100644
--- a/crates/sprout-mcp/src/relay_client.rs
+++ b/crates/sprout-mcp/src/relay_client.rs
@@ -543,6 +543,19 @@ impl RelayClient {
         Ok(resp.text().await?)
     }
 
+    /// Get the canvas content for a channel via REST.
+    pub async fn get_canvas(&self, channel_id: &str) -> anyhow::Result<String> {
+        self.get(&format!("/api/channels/{}/canvas", channel_id))
+            .await
+    }
+
+    /// Set the canvas content for a channel via REST.
+    pub async fn set_canvas(&self, channel_id: &str, content: &str) -> anyhow::Result<String> {
+        let body = serde_json::json!({ "content": content });
+        self.put(&format!("/api/channels/{}/canvas", channel_id), &body)
+            .await
+    }
+
     /// Authenticated GET to a full URL (for feed tools that build the URL themselves).
     pub async fn get_api(&self, url: &str) -> anyhow::Result<String> {
         let resp = self.apply_auth(self.http.get(url)).send().await?;
diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs
index d24ca71531..3479616a02 100644
--- a/crates/sprout-mcp/src/server.rs
+++ b/crates/sprout-mcp/src/server.rs
@@ -1,5 +1,3 @@
-use sprout_core::kind::KIND_CANVAS;
-
 use rmcp::{
     handler::server::{router::tool::ToolRouter, wrapper::Parameters},
     model::{ServerCapabilities, ServerInfo},
@@ -79,7 +77,7 @@ pub struct GetChannelHistoryParams {
 /// Parameters for the `list_channels` tool.
 #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
 pub struct ListChannelsParams {
-    /// Optional visibility filter: `"public"` or `"private"`.
+    /// Optional visibility filter: `"open"` or `"private"`.
     #[serde(default)]
     pub visibility: Option<String>,
 }
@@ -89,9 +87,9 @@ pub struct ListChannelsParams {
 pub struct CreateChannelParams {
     /// Display name for the new channel.
     pub name: String,
-    /// Channel type identifier (e.g. `"text"`, `"voice"`).
+    /// Channel type: `"stream"` (real-time chat) or `"forum"` (threaded discussions).
     pub channel_type: String,
-    /// Visibility of the channel: `"public"` or `"private"`.
+    /// Channel visibility: `"open"` (anyone can join) or `"private"` (invite-only).
     pub visibility: String,
     /// Optional human-readable description of the channel's purpose.
     #[serde(default)]
@@ -128,7 +126,19 @@ pub struct ListWorkflowsParams {
 pub struct CreateWorkflowParams {
     /// UUID of the channel to own this workflow.
     pub channel_id: String,
-    /// Full workflow definition in YAML format.
+    /// Full workflow definition in YAML format. Required fields: name (string), trigger (object with
+    /// 'on' field: 'message_posted', 'reaction_added', or 'webhook'), steps (array).
+    /// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific
+    /// fields as direct properties (NOT nested under 'params'). Example:
+    /// ```yaml
+    /// name: My Workflow
+    /// trigger:
+    ///   on: message_posted
+    /// steps:
+    ///   - id: notify
+    ///     action: send_message
+    ///     text: Hello from workflow!
+    /// ```
     pub yaml_definition: String,
 }
 
@@ -522,7 +532,10 @@ impl SproutMcpServer {
     }
 
     /// Create a new Sprout channel.
-    #[tool(name = "create_channel", description = "Create a new Sprout channel")]
+    #[tool(
+        name = "create_channel",
+        description = "Create a new Sprout channel. channel_type must be 'stream' or 'forum'. visibility must be 'open' or 'private'."
+    )]
     pub async fn create_channel(&self, Parameters(p): Parameters<CreateChannelParams>) -> String {
         let body = serde_json::json!({
             "name": p.name,
@@ -545,28 +558,19 @@ impl SproutMcpServer {
         if let Err(e) = validate_uuid(&p.channel_id) {
             return format!("Error: {e}");
         }
-
-        let filter = nostr::Filter::new()
-            .kind(nostr::Kind::Custom(KIND_CANVAS as u16))
-            .custom_tag(
-                nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
-                [p.channel_id.as_str()],
-            )
-            .limit(50);
-
-        let sub_id = format!("canvas-{}", uuid::Uuid::new_v4());
-        let events = match self.client.subscribe(&sub_id, vec![filter]).await {
-            Ok(e) => e,
-            Err(e) => return format!("Error: {e}"),
-        };
-        let _ = self.client.close_subscription(&sub_id).await;
-
-        let canvas_event = events.iter().max_by_key(|event| event.created_at.as_u64());
-
-        if let Some(event) = canvas_event {
-            event.content.clone()
-        } else {
-            "No canvas set for this channel.".to_string()
+        match self.client.get_canvas(&p.channel_id).await {
+            Ok(body) => {
+                // Parse REST JSON and return just the content string.
+                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
+                    match v.get("content").and_then(|c| c.as_str()) {
+                        Some(content) => content.to_string(),
+                        None => "No canvas set for this channel.".to_string(),
+                    }
+                } else {
+                    body
+                }
+            }
+            Err(e) => format!("Error: {e}"),
         }
     }
 
@@ -579,29 +583,20 @@ impl SproutMcpServer {
         if let Err(e) = validate_uuid(&p.channel_id) {
             return format!("Error: {e}");
         }
-
-        let keys = self.client.keys().clone();
-
-        let channel_tag = match nostr::Tag::parse(&["h", &p.channel_id]) {
-            Ok(t) => t,
-            Err(e) => return format!("Error building tag: {e}"),
-        };
-
-        let event = match nostr::EventBuilder::new(
-            nostr::Kind::Custom(KIND_CANVAS as u16),
-            &p.content,
-            [channel_tag],
-        )
-        .sign_with_keys(&keys)
-        {
-            Ok(e) => e,
-            Err(e) => return format!("Error signing event: {e}"),
-        };
-
-        match self.client.send_event(event).await {
-            Ok(ok) if ok.accepted => "Canvas updated.".to_string(),
-            Ok(ok) => format!("Canvas update rejected: {}", ok.message),
-            Err(e) => format!("Relay error: {e}"),
+        match self.client.set_canvas(&p.channel_id, &p.content).await {
+            Ok(body) => {
+                // Parse REST JSON; return a clean confirmation string.
+                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
+                    if v.get("ok").and_then(|o| o.as_bool()) == Some(true) {
+                        return "Canvas updated.".to_string();
+                    }
+                    if let Some(err) = v.get("error").and_then(|e| e.as_str()) {
+                        return format!("Error: {err}");
+                    }
+                }
+                body
+            }
+            Err(e) => format!("Error: {e}"),
         }
     }
 
@@ -629,7 +624,7 @@ impl SproutMcpServer {
     /// Create a new workflow in a channel from a YAML definition.
     #[tool(
         name = "create_workflow",
-        description = "Create a new workflow in a channel from a YAML definition"
+        description = "Create a new workflow from a YAML definition. Steps need 'id' (not 'name'), and action fields are direct properties (not nested under 'params'). Triggers: message_posted, reaction_added, webhook."
     )]
     pub async fn create_workflow(&self, Parameters(p): Parameters<CreateWorkflowParams>) -> String {
         if uuid::Uuid::parse_str(&p.channel_id).is_err() {
diff --git a/crates/sprout-relay/src/api/canvas.rs b/crates/sprout-relay/src/api/canvas.rs
new file mode 100644
index 0000000000..617546056d
--- /dev/null
+++ b/crates/sprout-relay/src/api/canvas.rs
@@ -0,0 +1,160 @@
+//! Canvas REST API.
+//!
+//! Endpoints:
+//!   GET /api/channels/:channel_id/canvas  — fetch the most recent canvas for a channel
+//!   PUT /api/channels/:channel_id/canvas  — set/update the canvas for a channel
+//!
+//! Canvas events are Nostr events of kind KIND_CANVAS (40100) with an "h" tag
+//! scoped to the channel UUID. The relay signs these events on behalf of the
+//! authenticated user (same pattern as `messages.rs`).
+
+use std::sync::Arc;
+
+use axum::{
+    extract::{Path, State},
+    http::{HeaderMap, StatusCode},
+    response::Json,
+};
+use nostr::util::hex as nostr_hex;
+use nostr::{EventBuilder, Kind, Tag};
+use serde::Deserialize;
+use sprout_core::kind::KIND_CANVAS;
+use sprout_db::event::EventQuery;
+
+use crate::handlers::event::dispatch_persistent_event;
+use crate::state::AppState;
+
+use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error};
+
+// ── GET /api/channels/:channel_id/canvas ─────────────────────────────────────
+
+/// Fetch the most recent canvas for a channel.
+///
+/// Returns the canvas content, author pubkey (hex), and updated_at timestamp
+/// (Unix seconds) if a canvas exists, or `{"content": null}` if none has been set.
+pub async fn get_canvas(
+    State(state): State<Arc<AppState>>,
+    headers: HeaderMap,
+    Path(channel_id_str): Path<String>,
+) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
+    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+
+    let channel_id = uuid::Uuid::parse_str(&channel_id_str)
+        .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
+
+    check_channel_access(&state, channel_id, &pubkey_bytes).await?;
+
+    // Query the events table for the most recent KIND_CANVAS event scoped to
+    // this channel. `channel_id` is stored as a column on the events row, so
+    // we can filter directly without scanning tags.
+    let q = EventQuery {
+        channel_id: Some(channel_id),
+        kinds: Some(vec![KIND_CANVAS as i32]),
+        pubkey: None,
+        since: None,
+        until: None,
+        limit: Some(1),
+        offset: None,
+    };
+
+    let events = state
+        .db
+        .query_events(&q)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+
+    match events.into_iter().next() {
+        Some(stored) => {
+            // Canvas events are relay-signed; the real author is in the first `p` tag.
+            let author_hex = stored
+                .event
+                .tags
+                .find(nostr::TagKind::SingleLetter(
+                    nostr::SingleLetterTag::lowercase(nostr::Alphabet::P),
+                ))
+                .and_then(|t| t.content().map(|s| s.to_string()))
+                .unwrap_or_else(|| nostr_hex::encode(stored.event.pubkey.serialize()));
+            let updated_at = stored.event.created_at.as_u64() as i64;
+            Ok(Json(serde_json::json!({
+                "content":    stored.event.content,
+                "updated_at": updated_at,
+                "author":     author_hex,
+            })))
+        }
+        None => Ok(Json(serde_json::json!({ "content": null }))),
+    }
+}
+
+// ── PUT /api/channels/:channel_id/canvas ─────────────────────────────────────
+
+/// Request body for setting the canvas.
+#[derive(Debug, Deserialize)]
+pub struct SetCanvasBody {
+    /// New canvas content (Markdown or plain text).
+    pub content: String,
+}
+
+/// Set or update the canvas for a channel.
+///
+/// Creates a Nostr event of kind KIND_CANVAS signed by the relay keypair,
+/// attributed to the authenticated user via a `p` tag. Fans out to WebSocket
+/// subscribers so live clients receive the update immediately.
+pub async fn set_canvas(
+    State(state): State<Arc<AppState>>,
+    headers: HeaderMap,
+    Path(channel_id_str): Path<String>,
+    Json(body): Json<SetCanvasBody>,
+) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
+    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+
+    let channel_id = uuid::Uuid::parse_str(&channel_id_str)
+        .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
+
+    check_channel_access(&state, channel_id, &pubkey_bytes).await?;
+
+    // Reject writes to archived channels (consistent with messages, metadata, etc.).
+    let channel = state
+        .db
+        .get_channel(channel_id)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+    if channel.archived_at.is_some() {
+        return Err(api_error(StatusCode::FORBIDDEN, "channel is archived"));
+    }
+
+    // Attribution: real author carried in a `p` tag; event is relay-signed.
+    let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes);
+
+    let tags: Vec<Tag> = vec![
+        // Real sender attribution (mirrors messages.rs pattern).
+        Tag::parse(&["p", &user_pubkey_hex])
+            .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+        // NIP-29 channel scope tag.
+        Tag::parse(&["h", &channel_id.to_string()])
+            .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+    ];
+
+    let kind = Kind::from(KIND_CANVAS as u16);
+
+    let event = EventBuilder::new(kind, &body.content, tags)
+        .sign_with_keys(&state.relay_keypair)
+        .map_err(|e| internal_error(&format!("event signing error: {e}")))?;
+
+    let event_id_hex = event.id.to_hex();
+
+    let (stored_event, was_inserted) = state
+        .db
+        .insert_event(&event, Some(channel_id))
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+
+    if was_inserted {
+        let _ =
+            dispatch_persistent_event(&state, &stored_event, KIND_CANVAS, &user_pubkey_hex).await;
+    }
+
+    Ok(Json(serde_json::json!({
+        "ok":       true,
+        "event_id": event_id_hex,
+    })))
+}
diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs
index 4b2e75a8f3..794d72bd17 100644
--- a/crates/sprout-relay/src/api/mod.rs
+++ b/crates/sprout-relay/src/api/mod.rs
@@ -14,6 +14,8 @@
 pub mod agents;
 /// Workflow approval grant/deny endpoints.
 pub mod approvals;
+/// Canvas (shared document) endpoints.
+pub mod canvas;
 /// Channel CRUD and membership endpoints.
 pub mod channels;
 /// Channel metadata endpoints (get, update, topic, purpose, archive).
@@ -44,6 +46,7 @@ pub mod workflows;
 // Re-export all public handlers so router.rs can use `api::*_handler` unchanged.
 pub use agents::agents_handler;
 pub use approvals::{deny_approval, grant_approval};
+pub use canvas::{get_canvas, set_canvas};
 pub use channels::{channels_handler, create_channel};
 pub use channels_metadata::{
     archive_channel_handler, delete_channel_handler, get_channel_handler, set_purpose_handler,
diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs
index d64dca7edd..ef366d4cd3 100644
--- a/crates/sprout-relay/src/router.rs
+++ b/crates/sprout-relay/src/router.rs
@@ -83,6 +83,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
             "/api/channels/{channel_id}/unarchive",
             post(api::unarchive_channel_handler),
         )
+        // Canvas routes
+        .route(
+            "/api/channels/{channel_id}/canvas",
+            get(api::get_canvas).put(api::set_canvas),
+        )
         // Message + thread routes
         .route(
             "/api/channels/{channel_id}/messages",
diff --git a/crates/sprout-test-client/src/bin/mention.rs b/crates/sprout-test-client/src/bin/mention.rs
index 69e52791e5..38732f6866 100644
--- a/crates/sprout-test-client/src/bin/mention.rs
+++ b/crates/sprout-test-client/src/bin/mention.rs
@@ -13,7 +13,7 @@ async fn main() -> anyhow::Result<()> {
     }
     let channel_id = &args[1];
     let target_pubkey = &args[2];
-    let message = &args[3];
+    let message = args[3..].join(" ");
 
     let url = std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".into());
     let keys = Keys::generate();
@@ -21,10 +21,10 @@ async fn main() -> anyhow::Result<()> {
 
     let mut client = SproutTestClient::connect(&url, &keys).await?;
 
-    let e_tag = Tag::parse(&["e", channel_id])?;
+    let h_tag = Tag::parse(&["h", channel_id])?;
     let p_tag = Tag::parse(&["p", target_pubkey])?;
     let event =
-        EventBuilder::new(Kind::Custom(40001), message, [e_tag, p_tag]).sign_with_keys(&keys)?;
+        EventBuilder::new(Kind::Custom(40001), message, [h_tag, p_tag]).sign_with_keys(&keys)?;
 
     let ok = client.send_event(event).await?;
     if ok.accepted {
diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs
index 72a224b7bb..9f6aefc53c 100644
--- a/crates/sprout-test-client/tests/e2e_mcp.rs
+++ b/crates/sprout-test-client/tests/e2e_mcp.rs
@@ -15,25 +15,18 @@
 //!
 //! # Auth
 //!
-//! The MCP server generates an ephemeral keypair on startup (no `SPROUT_PRIVATE_KEY`
-//! needed). In dev mode (`require_auth_token=false`) the relay accepts any
-//! authenticated NIP-42 client.
-//!
-//! # Channel setup
-//!
-//! Tests use the pre-seeded open channels that are stable across relay restarts.
+//! Each test generates a known keypair, creates a fresh channel via the REST API
+//! (so the keypair is the channel owner and member), then passes the private key
+//! as `SPROUT_PRIVATE_KEY` to the MCP server subprocess.  This ensures the MCP
+//! server uses a stable identity that has access to the channels under test.
 
 use std::io::{BufRead, BufReader, Write};
 use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
 use std::time::Duration;
 
+use nostr::{Keys, ToBech32};
 use serde_json::{json, Value};
 
-// ── Seeded channel IDs (UUID5-derived, stable across relay restarts) ──────────
-
-const CHANNEL_GENERAL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
-const CHANNEL_ENGINEERING: &str = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
-
 // ── Helpers ───────────────────────────────────────────────────────────────────
 
 /// WebSocket relay URL (e.g. `ws://localhost:3001`).
@@ -41,11 +34,63 @@ fn relay_ws_url() -> String {
     std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
 }
 
+/// HTTP relay URL derived from the WebSocket URL.
+fn relay_http_url() -> String {
+    relay_ws_url()
+        .replace("wss://", "https://")
+        .replace("ws://", "http://")
+        .trim_end_matches('/')
+        .to_string()
+}
+
+/// Generate a fresh Nostr keypair for a test run.
+fn generate_test_keys() -> Keys {
+    Keys::generate()
+}
+
+/// Encode the secret key as an `nsec1…` bech32 string.
+fn nsec_from_keys(keys: &Keys) -> String {
+    keys.secret_key().to_bech32().expect("bech32 encode nsec")
+}
+
+/// Create a fresh channel via the REST API using the given keypair as the owner.
+///
+/// Returns the new channel's UUID string.  The creating pubkey is automatically
+/// added as a member, so the MCP server (using the same keypair) will have
+/// access to it.
+async fn create_channel_for_test(keys: &Keys, name: &str) -> String {
+    let client = reqwest::Client::new();
+    let url = format!("{}/api/channels", relay_http_url());
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", keys.public_key().to_hex())
+        .json(&serde_json::json!({
+            "name": name,
+            "channel_type": "stream",
+            "visibility": "open",
+        }))
+        .send()
+        .await
+        .expect("create channel request failed");
+    assert_eq!(
+        resp.status(),
+        201,
+        "channel creation failed: {}",
+        resp.text().await.unwrap_or_default()
+    );
+    let body: serde_json::Value = resp.json().await.expect("parse channel response");
+    body["id"]
+        .as_str()
+        .expect("channel id in response")
+        .to_string()
+}
+
 /// Spawn the MCP server as a subprocess with stdin/stdout piped.
 ///
-/// The server connects to the relay and performs NIP-42 auth on startup.
-/// We give it a few seconds to complete the handshake before sending requests.
-fn spawn_mcp_server() -> Child {
+/// The server connects to the relay and performs NIP-42 auth on startup using
+/// the provided keypair (passed via `SPROUT_PRIVATE_KEY`).
+fn spawn_mcp_server(keys: &Keys) -> Child {
+    let nsec = nsec_from_keys(keys);
     Command::new("cargo")
         .args([
             "run",
@@ -56,6 +101,7 @@ fn spawn_mcp_server() -> Child {
             "--",
         ])
         .env("SPROUT_RELAY_URL", relay_ws_url())
+        .env("SPROUT_PRIVATE_KEY", &nsec)
         // Suppress verbose startup logs so they don't pollute stderr output.
         .env("RUST_LOG", "error")
         .stdin(Stdio::piped())
@@ -74,9 +120,9 @@ struct McpSession {
 }
 
 impl McpSession {
-    /// Spawn the MCP server and wait for it to connect to the relay.
-    async fn start() -> Self {
-        let mut child = spawn_mcp_server();
+    /// Spawn the MCP server with the given keypair and wait for it to connect.
+    async fn start(keys: &Keys) -> Self {
+        let mut child = spawn_mcp_server(keys);
         let stdin = child.stdin.take().expect("stdin not piped");
         let stdout = child.stdout.take().expect("stdout not piped");
         let reader = BufReader::new(stdout);
@@ -200,11 +246,12 @@ impl McpSession {
 // ── Tests ─────────────────────────────────────────────────────────────────────
 
 /// Spawn the MCP server, complete the initialize handshake, and verify that
-/// all 16 expected tools are listed by `tools/list`.
+/// all 36 expected tools are listed by `tools/list`.
 #[tokio::test]
 #[ignore]
 async fn test_mcp_initialize_and_list_tools() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let mut session = McpSession::start(&keys).await;
 
     // ── initialize ──────────────────────────────────────────────────────────
     let init_resp = session.initialize();
@@ -248,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
 
     assert_eq!(
         tools.len(),
-        16,
-        "expected exactly 16 tools, got {}. Tools: {:?}",
+        36,
+        "expected exactly 36 tools, got {}. Tools: {:?}",
         tools.len(),
         tools
             .iter()
@@ -277,6 +324,26 @@ async fn test_mcp_initialize_and_list_tools() {
         "get_feed",
         "get_feed_mentions",
         "get_feed_actions",
+        "add_channel_member",
+        "remove_channel_member",
+        "list_channel_members",
+        "join_channel",
+        "leave_channel",
+        "get_channel",
+        "update_channel",
+        "set_channel_topic",
+        "set_channel_purpose",
+        "archive_channel",
+        "unarchive_channel",
+        "send_reply",
+        "get_thread",
+        "open_dm",
+        "add_dm_member",
+        "list_dms",
+        "add_reaction",
+        "remove_reaction",
+        "get_reactions",
+        "set_profile",
     ];
 
     for expected in &expected_tools {
@@ -302,11 +369,19 @@ async fn test_mcp_initialize_and_list_tools() {
     session.stop();
 }
 
-/// Call `list_channels` via MCP and verify the response contains the seeded channels.
+/// Call `list_channels` via MCP and verify the response contains the channel
+/// we created for this test run.
 #[tokio::test]
 #[ignore]
 async fn test_mcp_list_channels() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let channel_id = create_channel_for_test(
+        &keys,
+        &format!("mcp-e2e-list-{}", uuid::Uuid::new_v4().simple()),
+    )
+    .await;
+
+    let mut session = McpSession::start(&keys).await;
     session.initialize();
 
     let resp = session.call_tool("list_channels", json!({}));
@@ -335,12 +410,12 @@ async fn test_mcp_list_channels() {
         "list_channels returned an empty channel list"
     );
 
-    // Verify the seeded general channel is present.
+    // Verify the channel we just created is present.
     let ids: Vec<&str> = channels.iter().filter_map(|ch| ch["id"].as_str()).collect();
 
     assert!(
-        ids.contains(&CHANNEL_GENERAL),
-        "expected seeded 'general' channel (id={CHANNEL_GENERAL}) in list, got: {ids:?}"
+        ids.contains(&channel_id.as_str()),
+        "expected created channel (id={channel_id}) in list, got: {ids:?}"
     );
 
     // Each channel must have the required fields.
@@ -361,7 +436,14 @@ async fn test_mcp_list_channels() {
 #[tokio::test]
 #[ignore]
 async fn test_mcp_send_and_read_message() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let channel_id = create_channel_for_test(
+        &keys,
+        &format!("mcp-e2e-msg-{}", uuid::Uuid::new_v4().simple()),
+    )
+    .await;
+
+    let mut session = McpSession::start(&keys).await;
     session.initialize();
 
     // Generate a unique message content so we can identify it in history.
@@ -372,7 +454,7 @@ async fn test_mcp_send_and_read_message() {
     let send_resp = session.call_tool(
         "send_message",
         json!({
-            "channel_id": CHANNEL_GENERAL,
+            "channel_id": channel_id,
             "content": content,
         }),
     );
@@ -384,8 +466,8 @@ async fn test_mcp_send_and_read_message() {
 
     let send_text = McpSession::tool_text(&send_resp);
     assert!(
-        send_text.contains("Message sent"),
-        "expected 'Message sent' in send_message response, got: {send_text}"
+        send_text.contains("event_id"),
+        "expected 'event_id' in send_message response, got: {send_text}"
     );
     assert!(
         !send_text.starts_with("Error"),
@@ -399,7 +481,7 @@ async fn test_mcp_send_and_read_message() {
     let history_resp = session.call_tool(
         "get_channel_history",
         json!({
-            "channel_id": CHANNEL_GENERAL,
+            "channel_id": channel_id,
             "limit": 20,
         }),
     );
@@ -415,9 +497,13 @@ async fn test_mcp_send_and_read_message() {
         "get_channel_history returned an error: {history_text}"
     );
 
-    let events: Vec<Value> = serde_json::from_str(&history_text).unwrap_or_else(|e| {
-        panic!("get_channel_history response is not valid JSON array: {e}\n{history_text}")
+    let history_json: Value = serde_json::from_str(&history_text).unwrap_or_else(|e| {
+        panic!("get_channel_history response is not valid JSON: {e}\n{history_text}")
     });
+    let events = history_json
+        .get("messages")
+        .and_then(|m| m.as_array())
+        .unwrap_or_else(|| panic!("expected 'messages' array in response: {history_text}"));
 
     let found = events
         .iter()
@@ -438,7 +524,14 @@ async fn test_mcp_send_and_read_message() {
 #[tokio::test]
 #[ignore]
 async fn test_mcp_search() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let channel_id = create_channel_for_test(
+        &keys,
+        &format!("mcp-e2e-search-{}", uuid::Uuid::new_v4().simple()),
+    )
+    .await;
+
+    let mut session = McpSession::start(&keys).await;
     session.initialize();
 
     // Generate a unique token that will appear in the search index.
@@ -449,7 +542,7 @@ async fn test_mcp_search() {
     let send_resp = session.call_tool(
         "send_message",
         json!({
-            "channel_id": CHANNEL_GENERAL,
+            "channel_id": channel_id,
             "content": content,
         }),
     );
@@ -461,8 +554,8 @@ async fn test_mcp_search() {
 
     let send_text = McpSession::tool_text(&send_resp);
     assert!(
-        send_text.contains("Message sent"),
-        "expected 'Message sent', got: {send_text}"
+        send_text.contains("event_id"),
+        "expected 'event_id' in send_message response, got: {send_text}"
     );
 
     // Wait for the search index to catch up.
@@ -485,7 +578,7 @@ async fn test_mcp_search() {
     let history_resp = session.call_tool(
         "get_channel_history",
         json!({
-            "channel_id": CHANNEL_GENERAL,
+            "channel_id": channel_id,
             "limit": 50,
         }),
     );
@@ -501,9 +594,13 @@ async fn test_mcp_search() {
         "get_channel_history returned an error: {history_text}"
     );
 
-    let events: Vec<Value> = serde_json::from_str(&history_text).unwrap_or_else(|e| {
+    let history_json: Value = serde_json::from_str(&history_text).unwrap_or_else(|e| {
         panic!("get_channel_history response is not valid JSON: {e}\n{history_text}")
     });
+    let events = history_json
+        .get("messages")
+        .and_then(|m| m.as_array())
+        .unwrap_or_else(|| panic!("expected 'messages' array in response: {history_text}"));
 
     let found = events
         .iter()
@@ -524,7 +621,14 @@ async fn test_mcp_search() {
 #[tokio::test]
 #[ignore]
 async fn test_mcp_create_and_trigger_workflow() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let channel_id = create_channel_for_test(
+        &keys,
+        &format!("mcp-e2e-wf-{}", uuid::Uuid::new_v4().simple()),
+    )
+    .await;
+
+    let mut session = McpSession::start(&keys).await;
     session.initialize();
 
     // A minimal webhook-triggered workflow (no external side effects).
@@ -543,7 +647,7 @@ async fn test_mcp_create_and_trigger_workflow() {
     let create_resp = session.call_tool(
         "create_workflow",
         json!({
-            "channel_id": CHANNEL_ENGINEERING,
+            "channel_id": channel_id,
             "yaml_definition": yaml_definition,
         }),
     );
@@ -555,9 +659,9 @@ async fn test_mcp_create_and_trigger_workflow() {
 
     let create_text = McpSession::tool_text(&create_resp);
     if create_text.starts_with("Error") {
-        // The MCP server uses an ephemeral keypair that may not exist in the
-        // users table (FK constraint on workflows.owner_pubkey).  This is a
-        // test-environment limitation, not a bug.  Skip gracefully.
+        // The MCP server uses a keypair that may not exist in the users table
+        // (FK constraint on workflows.owner_pubkey).  This is a test-environment
+        // limitation, not a bug.  Skip gracefully.
         eprintln!("Skipping workflow test — MCP keypair not in users table: {create_text}");
         session.stop();
         return;
@@ -583,7 +687,7 @@ async fn test_mcp_create_and_trigger_workflow() {
     let list_resp = session.call_tool(
         "list_workflows",
         json!({
-            "channel_id": CHANNEL_ENGINEERING,
+            "channel_id": channel_id,
         }),
     );
 
@@ -701,7 +805,8 @@ async fn test_mcp_create_and_trigger_workflow() {
 #[tokio::test]
 #[ignore]
 async fn test_mcp_feed_tools() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let mut session = McpSession::start(&keys).await;
     session.initialize();
 
     // ── get_feed ────────────────────────────────────────────────────────────
@@ -776,7 +881,14 @@ async fn test_mcp_feed_tools() {
 #[tokio::test]
 #[ignore]
 async fn test_mcp_canvas_set_and_get() {
-    let mut session = McpSession::start().await;
+    let keys = generate_test_keys();
+    let channel_id = create_channel_for_test(
+        &keys,
+        &format!("mcp-e2e-canvas-{}", uuid::Uuid::new_v4().simple()),
+    )
+    .await;
+
+    let mut session = McpSession::start(&keys).await;
     session.initialize();
 
     let unique_content = format!("MCP E2E canvas test: {}", uuid::Uuid::new_v4().simple());
@@ -785,7 +897,7 @@ async fn test_mcp_canvas_set_and_get() {
     let set_resp = session.call_tool(
         "set_canvas",
         json!({
-            "channel_id": CHANNEL_GENERAL,
+            "channel_id": channel_id,
             "content": unique_content,
         }),
     );
@@ -796,9 +908,9 @@ async fn test_mcp_canvas_set_and_get() {
     );
 
     let set_text = McpSession::tool_text(&set_resp);
-    assert!(
-        set_text.contains("Canvas updated"),
-        "expected 'Canvas updated' in set_canvas response, got: {set_text}"
+    assert_eq!(
+        set_text, "Canvas updated.",
+        "expected 'Canvas updated.' from set_canvas, got: {set_text}"
     );
 
     // Small delay for the event to propagate.
@@ -808,7 +920,7 @@ async fn test_mcp_canvas_set_and_get() {
     let get_resp = session.call_tool(
         "get_canvas",
         json!({
-            "channel_id": CHANNEL_GENERAL,
+            "channel_id": channel_id,
         }),
     );
 
@@ -818,9 +930,9 @@ async fn test_mcp_canvas_set_and_get() {
     );
 
     let get_text = McpSession::tool_text(&get_resp);
-    assert!(
-        get_text.contains(&unique_content),
-        "expected canvas content '{unique_content}' in get_canvas response, got: {get_text}"
+    assert_eq!(
+        get_text, unique_content,
+        "expected exact canvas content '{unique_content}' from get_canvas, got: {get_text}"
     );
 
     session.stop();
diff --git a/crates/sprout-test-client/tests/e2e_relay.rs b/crates/sprout-test-client/tests/e2e_relay.rs
index ef6e90d33e..ef8c9ed11b 100644
--- a/crates/sprout-test-client/tests/e2e_relay.rs
+++ b/crates/sprout-test-client/tests/e2e_relay.rs
@@ -31,8 +31,33 @@ fn sub_id(name: &str) -> String {
     format!("e2e-{name}-{}", uuid::Uuid::new_v4())
 }
 
-fn channel_id(name: &str) -> String {
-    format!("test-channel-{name}-{}", uuid::Uuid::new_v4())
+fn relay_http_url() -> String {
+    relay_url()
+        .replace("wss://", "https://")
+        .replace("ws://", "http://")
+        .trim_end_matches('/')
+        .to_string()
+}
+
+/// Create a real channel in the DB via REST so the relay accepts events for it.
+async fn create_test_channel(keys: &Keys) -> String {
+    let client = reqwest::Client::new();
+    let url = format!("{}/api/channels", relay_http_url());
+    let pubkey_hex = keys.public_key().to_hex();
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&serde_json::json!({
+            "name": format!("relay-e2e-{}", uuid::Uuid::new_v4()),
+            "channel_type": "stream",
+            "visibility": "open",
+        }))
+        .send()
+        .await
+        .expect("create channel request");
+    assert_eq!(resp.status(), 201, "channel creation failed");
+    let body: serde_json::Value = resp.json().await.expect("parse channel response");
+    body["id"].as_str().expect("channel id").to_string()
 }
 
 #[tokio::test]
@@ -52,11 +77,11 @@ async fn test_connect_and_authenticate() {
 #[ignore]
 async fn test_send_event_and_receive_via_subscription() {
     let url = relay_url();
-    let channel = channel_id("send-recv");
     let kind: u16 = 40001;
 
     let keys_a = Keys::generate();
     let keys_b = Keys::generate();
+    let channel = create_test_channel(&keys_a).await;
 
     let mut client_a = SproutTestClient::connect(&url, &keys_a)
         .await
@@ -111,11 +136,11 @@ async fn test_send_event_and_receive_via_subscription() {
 #[ignore]
 async fn test_subscription_filters_by_kind() {
     let url = relay_url();
-    let channel = channel_id("filter-kind");
     let target_kind: u16 = 40001;
     let other_kind: u16 = 40002;
 
     let keys = Keys::generate();
+    let channel = create_test_channel(&keys).await;
 
     let mut client = SproutTestClient::connect(&url, &keys)
         .await
@@ -183,10 +208,10 @@ async fn test_subscription_filters_by_kind() {
 #[ignore]
 async fn test_close_subscription_stops_delivery() {
     let url = relay_url();
-    let channel = channel_id("close-sub");
     let kind: u16 = 40001;
 
     let keys = Keys::generate();
+    let channel = create_test_channel(&keys).await;
     let mut client = SproutTestClient::connect(&url, &keys)
         .await
         .expect("connect");
@@ -275,10 +300,10 @@ async fn test_unauthenticated_rejected() {
 #[ignore]
 async fn test_multiple_concurrent_clients() {
     let url = relay_url();
-    let channel = channel_id("multi-client");
     let kind: u16 = 40001;
 
     let keys: Vec<Keys> = (0..3).map(|_| Keys::generate()).collect();
+    let channel = create_test_channel(&keys[0]).await;
 
     let mut clients: Vec<SproutTestClient> =
         futures_util::future::try_join_all(keys.iter().map(|k| SproutTestClient::connect(&url, k)))
@@ -332,10 +357,10 @@ async fn test_multiple_concurrent_clients() {
 #[ignore]
 async fn test_stored_events_returned_before_eose() {
     let url = relay_url();
-    let channel = channel_id("stored-events");
     let kind: u16 = 40001;
 
     let keys = Keys::generate();
+    let channel = create_test_channel(&keys).await;
     let mut client = SproutTestClient::connect(&url, &keys)
         .await
         .expect("connect");
@@ -376,10 +401,10 @@ async fn test_stored_events_returned_before_eose() {
 #[ignore]
 async fn test_ephemeral_event_not_stored() {
     let url = relay_url();
-    let channel = channel_id("ephemeral");
     let ephemeral_kind: u16 = 20001;
 
     let keys = Keys::generate();
+    let channel = create_test_channel(&keys).await;
     let mut client = SproutTestClient::connect(&url, &keys)
         .await
         .expect("connect");
@@ -566,10 +591,10 @@ async fn test_nip11_relay_info() {
 #[ignore]
 async fn test_pubkey_mismatch_rejected() {
     let url = relay_url();
-    let channel = channel_id("pubkey-mismatch");
 
     let keys_a = Keys::generate();
     let keys_b = Keys::generate();
+    let channel = create_test_channel(&keys_a).await;
 
     let mut client = SproutTestClient::connect(&url, &keys_a)
         .await
@@ -592,10 +617,10 @@ async fn test_pubkey_mismatch_rejected() {
 #[ignore]
 async fn test_eose_sent_for_empty_subscription() {
     let url = relay_url();
-    let channel = channel_id("empty-eose");
     let kind: u16 = 40001;
 
     let keys = Keys::generate();
+    let channel = create_test_channel(&keys).await;
     let mut client = SproutTestClient::connect(&url, &keys)
         .await
         .expect("connect");
diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs
index 8d99815f07..54c10a5d7c 100644
--- a/crates/sprout-test-client/tests/e2e_rest_api.rs
+++ b/crates/sprout-test-client/tests/e2e_rest_api.rs
@@ -20,10 +20,10 @@
 //!
 //! # Channel setup
 //!
-//! The relay exposes REST endpoints to list and create channels. Tests use the
-//! pre-seeded open channels (`general`, `agents`, `engineering`, etc.) for read
-//! operations and create temporary channels for write coverage when needed.
-//! Some tests also send messages via WebSocket to set up search / feed data.
+//! Each test creates its own channels dynamically via `POST /api/channels`.
+//! No pre-seeded data is required — tests are fully self-contained and work
+//! against a fresh database. Some tests also send messages via WebSocket to
+//! set up search / feed data.
 
 use std::time::Duration;
 
@@ -79,13 +79,6 @@ async fn authed_post_json(
         .unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}"))
 }
 
-/// Known open channel IDs seeded in the dev database.
-///
-/// These are UUID5-derived from the channel name and are stable across relay
-/// restarts as long as the seed data uses the same namespace + name inputs.
-const CHANNEL_GENERAL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
-const CHANNEL_ENGINEERING: &str = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
-
 // ── Channel tests ─────────────────────────────────────────────────────────────
 
 /// GET /api/channels returns a non-empty list with the expected fields.
@@ -97,6 +90,26 @@ async fn test_list_channels_returns_expected_fields() {
     let pubkey_hex = keys.public_key().to_hex();
 
     let url = format!("{}/api/channels", relay_http_url());
+
+    // Ensure at least one channel exists (fresh DB may be empty).
+    let seed_resp = authed_post_json(
+        &client,
+        &url,
+        &pubkey_hex,
+        serde_json::json!({
+            "name": format!("list-test-{}", uuid::Uuid::new_v4()),
+            "channel_type": "stream",
+            "visibility": "open",
+            "description": "Seed channel for list test"
+        }),
+    )
+    .await;
+    assert_eq!(
+        seed_resp.status(),
+        201,
+        "bootstrap channel creation must succeed"
+    );
+
     let resp = authed_get(&client, &url, &pubkey_hex).await;
 
     assert_eq!(resp.status(), 200, "expected 200 OK from /api/channels");
@@ -190,6 +203,30 @@ async fn test_channel_visibility_open_channels_visible_to_all() {
 
     let url = format!("{}/api/channels", relay_http_url());
 
+    // Create an open channel as keys_a so there is at least one open channel to verify.
+    let open_channel_name = format!("e2e-open-{}", uuid::Uuid::new_v4().simple());
+    let create_resp = authed_post_json(
+        &client,
+        &url,
+        &keys_a.public_key().to_hex(),
+        serde_json::json!({
+            "name": open_channel_name,
+            "channel_type": "stream",
+            "visibility": "open"
+        }),
+    )
+    .await;
+    assert_eq!(
+        create_resp.status(),
+        201,
+        "failed to create open channel for visibility test"
+    );
+    let created_channel: serde_json::Value = create_resp.json().await.expect("JSON");
+    let open_channel_id = created_channel["id"]
+        .as_str()
+        .expect("created channel must have id")
+        .to_string();
+
     let resp_a = authed_get(&client, &url, &keys_a.public_key().to_hex()).await;
     let resp_b = authed_get(&client, &url, &keys_b.public_key().to_hex()).await;
 
@@ -199,7 +236,7 @@ async fn test_channel_visibility_open_channels_visible_to_all() {
     let channels_a: Vec<serde_json::Value> = resp_a.json().await.expect("JSON");
     let channels_b: Vec<serde_json::Value> = resp_b.json().await.expect("JSON");
 
-    // Both users should see the same set of open channels.
+    // Both users should see the open channel we just created.
     let ids_a: std::collections::HashSet<String> = channels_a
         .iter()
         .filter_map(|c| c["id"].as_str().map(|s| s.to_string()))
@@ -209,19 +246,13 @@ async fn test_channel_visibility_open_channels_visible_to_all() {
         .filter_map(|c| c["id"].as_str().map(|s| s.to_string()))
         .collect();
 
-    assert_eq!(
-        ids_a, ids_b,
-        "two fresh users should see the same set of open channels"
-    );
-
-    // The well-known seeded channels must be present.
     assert!(
-        ids_a.contains(CHANNEL_GENERAL),
-        "expected seeded 'general' channel (id={CHANNEL_GENERAL})"
+        ids_a.contains(&open_channel_id),
+        "keys_a should see the open channel we created (id={open_channel_id})"
     );
     assert!(
-        ids_a.contains(CHANNEL_ENGINEERING),
-        "expected seeded 'engineering' channel (id={CHANNEL_ENGINEERING})"
+        ids_b.contains(&open_channel_id),
+        "keys_b (unrelated user) should also see the open channel (id={open_channel_id})"
     );
 }
 
@@ -235,14 +266,36 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() {
     let poster_keys = Keys::generate();
     let ws_url = relay_ws_url();
 
+    // Create a fresh open channel for this test.
+    let channels_url = format!("{}/api/channels", relay_http_url());
+    let channel_name = format!("e2e-rest-live-{}", uuid::Uuid::new_v4().simple());
+    let create_resp = authed_post_json(
+        &client,
+        &channels_url,
+        &poster_keys.public_key().to_hex(),
+        serde_json::json!({
+            "name": channel_name,
+            "channel_type": "stream",
+            "visibility": "open"
+        }),
+    )
+    .await;
+    assert_eq!(create_resp.status(), 201, "failed to create test channel");
+    let created: serde_json::Value = create_resp.json().await.expect("JSON");
+    let channel_id = created["id"]
+        .as_str()
+        .expect("channel must have id")
+        .to_string();
+
     let mut subscriber = SproutTestClient::connect(&ws_url, &subscriber_keys)
         .await
         .expect("WebSocket connect failed");
 
     let sid = format!("rest-live-{}", uuid::Uuid::new_v4().simple());
-    let filter = Filter::new()
-        .kind(Kind::Custom(40001))
-        .custom_tag(SingleLetterTag::lowercase(Alphabet::H), [CHANNEL_GENERAL]);
+    let filter = Filter::new().kind(Kind::Custom(40001)).custom_tag(
+        SingleLetterTag::lowercase(Alphabet::H),
+        [channel_id.as_str()],
+    );
 
     subscriber
         .subscribe(&sid, vec![filter])
@@ -254,11 +307,7 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() {
         .expect("EOSE failed");
 
     let content = format!("E2E REST live message: {}", uuid::Uuid::new_v4().simple());
-    let url = format!(
-        "{}/api/channels/{}/messages",
-        relay_http_url(),
-        CHANNEL_GENERAL
-    );
+    let url = format!("{}/api/channels/{}/messages", relay_http_url(), channel_id);
     let resp = authed_post_json(
         &client,
         &url,
@@ -286,7 +335,7 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() {
 
             assert!(
                 tags.iter()
-                    .any(|tag| tag.len() >= 2 && tag[0] == "h" && tag[1] == CHANNEL_GENERAL),
+                    .any(|tag| tag.len() >= 2 && tag[0] == "h" && tag[1] == channel_id),
                 "REST-created message is missing the channel h tag: {tags:?}"
             );
             assert!(
@@ -363,6 +412,27 @@ async fn test_search_returns_indexed_event() {
     let pubkey_hex = keys.public_key().to_hex();
     let ws_url = relay_ws_url();
 
+    // Create a channel for this test so the event is accepted by the relay.
+    let channels_url = format!("{}/api/channels", relay_http_url());
+    let channel_name = format!("e2e-search-{}", uuid::Uuid::new_v4().simple());
+    let create_resp = authed_post_json(
+        &client,
+        &channels_url,
+        &pubkey_hex,
+        serde_json::json!({
+            "name": channel_name,
+            "channel_type": "stream",
+            "visibility": "open"
+        }),
+    )
+    .await;
+    assert_eq!(create_resp.status(), 201, "failed to create test channel");
+    let created: serde_json::Value = create_resp.json().await.expect("JSON");
+    let channel_id = created["id"]
+        .as_str()
+        .expect("channel must have id")
+        .to_string();
+
     let unique_token = format!("e2e-search-{}", uuid::Uuid::new_v4().simple());
     let content = format!("E2E REST search test marker: {unique_token}");
 
@@ -370,8 +440,8 @@ async fn test_search_returns_indexed_event() {
         .await
         .expect("WebSocket connect failed");
 
-    let e_tag = Tag::parse(&["e", CHANNEL_GENERAL]).expect("tag parse failed");
-    let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [e_tag])
+    let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed");
+    let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag])
         .sign_with_keys(&keys)
         .expect("event sign failed");
 
@@ -673,7 +743,28 @@ async fn test_feed_returns_activity() {
         return;
     }
 
-    // Send a message to an open channel so there is activity to return.
+    // Create a channel for this test so the event is accepted by the relay.
+    let channels_url = format!("{}/api/channels", relay_http_url());
+    let channel_name = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple());
+    let create_resp = authed_post_json(
+        &client,
+        &channels_url,
+        &pubkey_hex,
+        serde_json::json!({
+            "name": channel_name,
+            "channel_type": "stream",
+            "visibility": "open"
+        }),
+    )
+    .await;
+    assert_eq!(create_resp.status(), 201, "failed to create test channel");
+    let created_channel: serde_json::Value = create_resp.json().await.expect("JSON");
+    let channel_id = created_channel["id"]
+        .as_str()
+        .expect("channel must have id")
+        .to_string();
+
+    // Send a message to the open channel so there is activity to return.
     let unique_token = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple());
     let content = format!("E2E feed test: {unique_token}");
 
@@ -681,8 +772,8 @@ async fn test_feed_returns_activity() {
         .await
         .expect("WebSocket connect failed");
 
-    let e_tag = Tag::parse(&["e", CHANNEL_GENERAL]).expect("tag parse failed");
-    let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [e_tag])
+    let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed");
+    let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag])
         .sign_with_keys(&keys)
         .expect("event sign failed");
 
