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 43 MCP tools against a live relay.
| 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 43 MCP tools via real Nostr identities |
Before merging relay/MCP/auth changes; full regression run; exploring new features |
Run both modes for a complete regression check. Run automated-only for a fast sanity check.
- Prerequisites
- Quick Start: Automated Tests Only
- Multi-Agent E2E Testing
- Advanced: ACP Harness Scenarios
- Workflow YAML Reference
- The 43 MCP Tools
- Cleanup
- Known Issues / Troubleshooting
- Proxy Tests
Verify each requirement before proceeding. All commands must succeed.
docker --version
# Required: any recent version
docker compose version
# Required: v2+ (uses "docker compose", not "docker-compose")# 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 newergoose --version
# Must be on $PATH and configured with a valid provider/model
goose run --help | head -5
# Must not errorsqlx --version
# If missing:
# pgschema manages the schema now — sqlx-cli is no longer neededscreen --version
# Must print a version string (note: on macOS this exits with code 1 — that's fine)
# If missing: brew install screenIf all commands above print version info, proceed. If any binary is missing, install it first — the tests will not work without all prerequisites.
Run this when you want a fast check without spinning up multi-agent infrastructure.
# 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-hermitIf 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:
# 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 postgres/redis/typesense show "Up", you can skip to "Setup and build" below.
# If not running:
docker compose up -dPort conflicts: If
docker compose up -dfails with "port already allocated", a container from another project may be using the port. Find it withdocker ps --format '{{.Names}} {{.Ports}}'and stop it manually.
Keycloak: You may see
sprout-keycloakasunhealthyorstarting— this is fine. Keycloak is only needed for token-based auth and is not required for automated tests (which use dev-modeX-Pubkeyheader auth). You may also see extra containers likesprout-postgresfrom other projects — ignore them.
# 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-postgres psql -U sprout -d postgres -c \
"DROP DATABASE IF EXISTS sprout; CREATE DATABASE sprout;" 2>/dev/null
./bin/pgschema apply --file schema/schema.sql --auto-approve
# Build the full workspace (relay, MCP server, ACP harness, test client, etc.)
cargo build --release --workspace
# Run unit tests
cargo test --workspaceStart the relay (kill any stale instance first):
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: okThen run the integration suites:
# REST API integration tests (40 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_rest_api -- --ignored
# WebSocket relay integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_relay -- --ignored
# MCP server integration tests (14 tests)
RELAY_URL=ws://localhost:3000 \
cargo test -p sprout-test-client --test e2e_mcp -- --ignoredtest result: ok. 40 passed; 0 failed; 0 ignored ← REST API
test result: ok. 14 passed; 0 failed; 0 ignored ← relay
test result: ok. 14 passed; 0 failed; 0 ignored ← MCP
All 68 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in e2e_workflows.rs — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E.
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
(43 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
@mentionsinto a single prompt - Auto-respawns the agent subprocess on crash
- Reconnects to the relay with a
sincefilter on disconnect (no missed events) GOOSE_MODE=autois mandatory — prevents goose from pausing for permission prompts
Run all commands from the sprout repo root.
cd /path/to/sprout
. bin/activate-hermit
# 1. Start Docker services (Postgres, 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. Apply database schema
./bin/pgschema apply --file schema/schema.sql --auto-approve
# 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 similarEach agent needs its own Nostr keypair. Use sprout-admin to mint them — it handles all database interaction internally.
# 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 ""
doneEach 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:
# 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>"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
Start one sprout-acp instance per agent in a dedicated screen session. GOOSE_MODE=auto is required on all three.
# 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'Wait ~5 seconds for all three to connect, then verify:
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 ""
doneExpected 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).
Bootstrap channel timing: Harnesses discover channels only at startup. If you create the bootstrap channel (in exercise A-1) after launching harnesses, Alice's harness won't be subscribed to it. Two options:
- Create the bootstrap channel before launching harnesses (recommended): run the
curl -X POSTcommand from A-1 first, then start all three harnesses.- Restart Alice's harness after creating the bootstrap channel — it will discover and subscribe to it on reconnect.
All exercises are delivered via @mention events using the mention binary:
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).
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.
# 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"
}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:
# 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"Then send Alice her first task:
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."Wait for Alice to complete (~30–60s), then capture channel UUIDs:
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"A-2: Channel metadata and canvas
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."A-3: Thread and reactions
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
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, NIP-05 identity, and presence
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 NIP-05 handle to 'alice@localhost' using set_profile. Then use set_presence to set your status to 'online'. Finally, use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY) and confirm it shows 'online'."A-6: Feed, search, and membership
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."Bob explores the environment Alice created and interacts with her content.
B-1: Discovery and history
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?"B-2: Reactions and DM
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!'"B-3: DM history and canvas
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
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
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
mention "$GENERAL" "$BOB_PUBKEY" \
"Get the message history from the 'private-ops' channel (ID: $PRIVATE_OPS). Alice invited you in exercise A-6 — confirm you have access and report what you find."B-7: Get presence
mention "$GENERAL" "$BOB_PUBKEY" \
"Use set_presence to set your status to 'away'. Then get the presence status for Alice (pubkey: $ALICE_PUBKEY) and yourself. Report both statuses — Alice should be 'online' (from A-5) and you should be 'away'."B-8: Profile resolution (public profiles)
mention "$GENERAL" "$BOB_PUBKEY" \
"Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list."B-8: Profile resolution (public profiles)
mention "$GENERAL" "$BOB_PUBKEY" \
"Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list."Charlie tests error handling, idempotency, and lifecycle operations.
C-1: Non-existent channel error
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
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Try to archive the 'general' channel (ID: $GENERAL). You did not create it — report what error you get."C-3: Canvas overwrite
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
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?"C-5: Channel lifecycle (create → archive → send → unarchive → send)
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."C-6: Join, send, leave, verify
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."C-7: Workflow trigger and cross-agent summary
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."C-8: NIP-05 identity verification
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Verify the NIP-05 endpoint. Alice set her NIP-05 handle to 'alice@localhost' in exercise A-5. Use curl or an HTTP request to GET http://localhost:3000/.well-known/nostr.json?name=alice — it should return her pubkey in the 'names' map and a relay URL in the 'relays' map. Also try ?name=nonexistent and confirm it returns empty names/relays. Check that the response includes an Access-Control-Allow-Origin: * header. Report your findings."C-9: Profile edge cases
mention "$GENERAL" "$CHARLIE_PUBKEY" \
"Test profile edge cases. Use get_user_profile with a pubkey that doesn't exist — report the error. Use get_users_batch with a mix of valid pubkeys, an invalid-length string like 'tooshort', and a string that is 64 chars but not valid hex. Report what ends up in the profiles map vs the missing list."# 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.logNote: Do NOT use
screen -rto attach to harness sessions if you are an AI agent — it requires an interactive TTY and will hang indefinitely. Always usetailon the log files orgrepfor 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))
# Investigate — agent had trouble
turn complete for channel <uuid>: max_tokens
turn timeout (300s) for channel <uuid> — cancelling
# Bad — needs attention
agent process exited — respawning
relay connection lost — reconnecting
# 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# 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]'# 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.txtAfter 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 |
| Profile resolution | Bob can read Alice's profile via get_user_profile; get_users_batch returns Alice and Bob with display names, Charlie with null display name (all in profiles map) |
| NIP-05 verification | Charlie queries /.well-known/nostr.json?name=alice and gets Alice's pubkey (Alice set alice@localhost in A-5) |
| Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys |
| Presence | Alice is 'online' (A-5), Bob is 'away' (B-7); Charlie can read both via get_presence (C-7) |
| 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:
# 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}'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.
# 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 5mention "$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.
# 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# 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 -10Expected log sequence: agent process exited — respawning → agent respawned successfully → agent initialized → turn complete.
# 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# 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.
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# 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 eachVerify channel membership via REST API:
curl -s -H "X-Pubkey: $AGENT_PUBKEY" \
"http://localhost:3000/api/channels" \
| jq '.[] | {id: .id, name: .name}'# 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# 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; doneWorkflows are created via the create_workflow MCP tool. The YAML structure:
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 messagewebhook— fires when the webhook URL is called via HTTP POST
Step field rules:
idis 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 aparamskey create_workflowtool accepts the YAML as a string parameter
Example: message_posted workflow
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
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!"The sprout-mcp-server exposes 43 tools covering the full Sprout feature surface. All are available to agents running via the sprout-acp harness.
| Tool | Description |
|---|---|
list_channels |
List all channels accessible to the agent |
get_channel |
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 |
| Tool | Description |
|---|---|
send_message |
Post a message to a channel |
send_diff_message |
Send a code diff to a channel with syntax highlighting and structured metadata |
get_channel_history |
Get recent messages from a channel |
| Tool | Description |
|---|---|
send_reply |
Reply within a message thread |
get_thread |
Get replies in a thread |
| Tool | Description |
|---|---|
add_reaction |
Add an emoji reaction to a message |
remove_reaction |
Remove a reaction |
get_reactions |
List all reactions on a message |
| Tool | Description |
|---|---|
open_dm |
Create or retrieve a DM channel with a user (optionally send an initial message) |
add_dm_member |
Add a member to an existing DM conversation |
list_dms |
List all DM conversations |
| Tool | Description |
|---|---|
get_canvas |
Read the canvas document for a channel |
set_canvas |
Write/overwrite the canvas document (last writer wins) |
| Tool | Description |
|---|---|
list_workflows |
List all workflows |
create_workflow |
Create a new workflow with trigger and steps |
update_workflow |
Update an existing workflow |
delete_workflow |
Delete a workflow |
trigger_workflow |
Manually trigger a webhook workflow |
get_workflow_runs |
Get execution history for a workflow |
approve_workflow_step |
Approve a pending approval step in a workflow run |
| Tool | Description |
|---|---|
get_feed |
Get the agent's personal activity feed |
get_feed_mentions |
Get mentions from the agent's feed |
get_feed_actions |
Get action items from the agent's feed |
| Tool | Description |
|---|---|
search |
Full-text search across messages and channels |
| Tool | Description |
|---|---|
set_profile |
Set display name, about/bio, avatar URL, and NIP-05 handle |
get_user_profile |
Get any user's profile by pubkey (omit pubkey for own profile) |
get_users_batch |
Bulk resolve display names and NIP-05 handles for multiple pubkeys |
| Tool | Description |
|---|---|
get_presence |
Bulk presence lookup by pubkey |
set_presence |
Set presence status (online/away/offline) with TTL |
| Tool | Description |
|---|---|
add_channel_member |
Add a user (by pubkey) to a channel |
remove_channel_member |
Remove a member from a channel |
list_channel_members |
List members of a channel |
| Tool | Description |
|---|---|
set_channel_topic |
Set the topic for a channel |
set_channel_purpose |
Set the purpose for a channel |
| Tool | Description |
|---|---|
set_channel_add_policy |
Set the caller's channel-add policy (anyone, owner_only, nobody) |
# 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
donescreen -S relay -X quit 2>/dev/null && echo "relay stopped"screen -ls
# Should show "No Sockets found" or only unrelated sessions# Stop services and remove volumes (full reset)
docker compose down -v
# Stop services only (preserve data for next run)
docker compose downrm -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.txtAll automated tests pass as of 2026-03-11:
- ✅ 40/40 REST API integration tests
- ✅ 14/14 WebSocket relay integration tests
- ✅ 14/14 MCP server integration tests
- ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness
Cause: SPROUT_PRIVATE_KEY not set or invalid.
echo $SPROUT_PRIVATE_KEY
# Must be a valid nsec1... bech32 stringCause: Relay not running or wrong URL.
# 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/healthCause: Agent pubkey is not a member of any open channels.
# Check what channels are accessible
curl -s -H "X-Pubkey: $ALICE_PUBKEY" \
"http://localhost:3000/api/channels" | jq 'length'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.
Cause: goose binary not found or not on $PATH.
which goose
goose --versionEnsure goose is installed and configured with a valid provider/model before starting the harness.
Cause: GOOSE_MODE=auto not set — goose is waiting for permission approval.
# 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'GOOSE_MODE=auto is mandatory for all harness instances. Without it, the first MCP tool call will hang indefinitely.
Checklist:
- Is the harness running?
pgrep -a sprout-acp - Is the harness subscribed to the target channel?
grep "subscribed" /tmp/agent-alice.log - Did
mentionuse the correct pubkey hex? Check$ALICE_PUBKEYis set correctly. - Check harness logs for errors:
tail -50 /tmp/agent-alice.log - Verify via REST API that the @mention event arrived:
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]}'Cause: sprout-mcp-server binary not found, or wrong relay URL passed to MCP.
which sprout-mcp-server
# If missing: cargo build --release -p sprout-mcp-server
# Then: export PATH="$PWD/target/release:$PATH"If $GENERAL, $ALICE_TESTING, or $PRIVATE_OPS are empty, Alice may not have finished yet. Wait longer, then re-query:
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"If still empty, check Alice's harness logs for errors: tail -50 /tmp/agent-alice.log.
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 compose ps
# If any service is not "Up":
docker compose down -v && docker compose up -d
# Wait 30s then re-apply schema:
./bin/pgschema apply --file schema/schema.sql --auto-approveThe sprout-proxy crate has its own test suite, separate from the relay tests above.
cargo test -p sprout-proxyRuns 79 unit tests covering kind translation, filter splitting, shadow keys, channel map, and access control.
# Start relay and proxy
just relay &
just proxy &
# Shell-based integration tests (8 tests)
./scripts/test-proxy-e2e-live.sh
# nostr-tools v2.23 E2E (6 tests) — requires Node.js
cd scripts && cp nostr-tools-test-package.json package.json && npm install && node test-proxy-nostr-tools.mjs
# nostr-sdk v0.44 E2E (5 tests) — requires Python + nostr-sdk
python3 scripts/test-proxy-nostr-sdk-python.pySee NOSTR.md for proxy setup, guest registration, and client configuration.
Manual testing guide for the Sprout Agent Channel Protection feature. Follow these steps against a running Sprout instance to verify all 13 acceptance criteria.
Placeholder convention: Replace
<agent-hex>,<owner-hex>, and<stranger-hex>with real 32-byte hex pubkeys before running commands. See §1.4 for how to generate them.
- Running Sprout relay in dev mode with
require_auth_token=falsedisabled (auth tokens required for all tests) - Postgres database with schema applied via pgschema (see §2.2)
- Default relay URL:
http://localhost:3001— adjust if different
| Tool | Purpose | Install |
|---|---|---|
curl |
REST API testing | Pre-installed on macOS/Linux |
websocat |
NIP-29 WebSocket testing | brew install websocat or cargo install websocat |
psql / postgresql-client |
DB verification queries | brew install postgresql |
sprout-admin |
Minting agent tokens | Built from crates/sprout-admin/ |
jq |
Pretty-print JSON responses | brew install jq |
cd /path/to/sprout
cargo build -p sprout-admin
# Binary at: target/debug/sprout-admin
alias sprout-admin="./target/debug/sprout-admin"You need three distinct keypairs: agent, owner, and stranger.
# Generate three 32-byte hex private keys (use as pubkeys for testing)
export AGENT_HEX=$(openssl rand -hex 32)
export OWNER_HEX=$(openssl rand -hex 32)
export STRANGER_HEX=$(openssl rand -hex 32)
echo "AGENT: $AGENT_HEX"
echo "OWNER: $OWNER_HEX"
echo "STRANGER: $STRANGER_HEX"Note: In production Nostr, pubkeys are derived from private keys via secp256k1. For manual testing against Sprout's REST API (which accepts
X-Pubkeyheaders directly), random 32-byte hex values work as stand-in pubkeys. For NIP-29 WebSocket tests you need real signed events — see §4.
Sprout REST endpoints require a Bearer token. Mint tokens for each test identity:
# Mint agent token (with owner set)
AGENT_TOKEN=$(sprout-admin mint-token \
--name "test-agent" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$AGENT_HEX" \
--owner-pubkey "$OWNER_HEX")
# Mint owner token
OWNER_TOKEN=$(sprout-admin mint-token \
--name "test-owner" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$OWNER_HEX")
# Mint stranger token (no relationship to agent)
STRANGER_TOKEN=$(sprout-admin mint-token \
--name "test-stranger" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$STRANGER_HEX")
echo "AGENT_TOKEN: $AGENT_TOKEN"
echo "OWNER_TOKEN: $OWNER_TOKEN"
echo "STRANGER_TOKEN: $STRANGER_TOKEN"cd /path/to/sprout
# Copy and configure .env if not already done
cp .env.example .env
# Edit .env: set DATABASE_URL, PORT=3001, etc.
cargo run -p sprout-relay
# Or with justfile:
just devVerify relay is up:
curl -s http://localhost:3001/health | jq .
# Expected: {"status":"ok"} or similarThe agent_channel_protection migration adds agent_owner_pubkey and channel_add_policy to the users table.
# Apply schema via pgschema
./bin/pgschema apply --file schema/schema.sql --auto-approve
# Or via justfile if configured
just migrateVerify schema applied:
docker exec sprout-postgres psql -U sprout -d sprout -c "\d users" | grep -E "agent_owner|channel_add"
# Expected output:
# agent_owner_pubkey | bytea | | |
# channel_add_policy | channel_add_policy | | not null | 'anyone'::channel_add_policyAll REST member tests require a channel ID. Create one with the owner token:
CHANNEL_ID=$(curl -s -X POST http://localhost:3001/api/channels \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "test-channel", "visibility": "open"}' \
| jq -r '.id')
echo "CHANNEL_ID: $CHANNEL_ID"Confirm each token authenticates as the expected pubkey:
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $AGENT_TOKEN" | jq .pubkey
# Expected: "<agent-hex>"
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $OWNER_TOKEN" | jq .pubkey
# Expected: "<owner-hex>"All tests use http://localhost:3001. Adjust port as needed.
Response shape for member add:
POST /api/channels/{id}/membersalways returns200 OKwith{"added": [...], "errors": [...]}. Policy violations appear inerrors, not as HTTP error codes.
Test: Set policy to owner_only
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .Expected response (200 OK):
{
"channel_add_policy": "owner_only",
"agent_owner_pubkey": "<owner-hex>"
}Test: Set policy to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .Expected response (200 OK):
{
"channel_add_policy": "nobody",
"agent_owner_pubkey": "<owner-hex>"
}Test: Reset policy to anyone
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}' | jq .Expected response (200 OK):
{
"channel_add_policy": "anyone",
"agent_owner_pubkey": "<owner-hex>"
}Test: Invalid policy value → 400
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "invite_only"}' | jq .Expected response (400 Bad Request):
{
"error": "channel_add_policy must be 'anyone', 'owner_only', or 'nobody'"
}Verify that a fresh agent (policy = anyone) can be added by any authenticated user.
# Reset agent policy to anyone first
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}' | jq .channel_add_policy
# Expected: "anyone"
# Stranger adds agent to channel — should succeed
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .Expected response (200 OK):
{
"added": ["<agent-hex>"],
"errors": []
}# Set agent policy to owner_only
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .channel_add_policy
# Expected: "owner_only"
# Stranger tries to add agent — should be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .Expected response (200 OK):
{
"added": [],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:owner_only — only the agent owner can add this agent"
}
]
}# Agent policy is still owner_only from 3.3
# Owner adds agent — should succeed
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .Expected response (200 OK):
{
"added": ["<agent-hex>"],
"errors": []
}# Set agent policy to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .channel_add_policy
# Expected: "nobody"
# Owner tries to add agent — should be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .
# Stranger tries to add agent — should also be blocked
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $STRANGER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .Expected response for both (200 OK):
{
"added": [],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:nobody — this agent has disabled external channel additions"
}
]
}# Agent policy is still nobody from 3.5
# Agent adds ITSELF — should succeed regardless of policy
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$AGENT_HEX\"], \"role\": \"member\"}" | jq .Expected response (200 OK):
{
"added": ["<agent-hex>"],
"errors": []
}Test that a batch add with both allowed and blocked pubkeys returns partial success.
# Set agent policy to nobody (blocked), stranger has default anyone (allowed)
# We'll add stranger to the channel and try to add agent in same batch
# Reset agent to nobody
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}' | jq .channel_add_policy
# Batch: owner adds both stranger (allowed) and agent (blocked by nobody)
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$STRANGER_HEX\", \"$AGENT_HEX\"], \"role\": \"member\"}" | jq .Expected response (200 OK):
{
"added": ["<stranger-hex>"],
"errors": [
{
"pubkey": "<agent-hex>",
"error": "policy:nobody — this agent has disabled external channel additions"
}
]
}Test the edge case where an agent has owner_only policy but no agent_owner_pubkey set.
# Mint a new agent WITHOUT --owner-pubkey
NO_OWNER_HEX=$(openssl rand -hex 32)
NO_OWNER_TOKEN=$(sprout-admin mint-token \
--name "test-agent-no-owner" \
--scopes "messages:read,messages:write,channels:read,channels:write" \
--pubkey "$NO_OWNER_HEX")
# Set policy to owner_only (no owner set — misconfiguration)
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $NO_OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}' | jq .
# Anyone tries to add — should be blocked with "no owner set" message
curl -s -X POST "http://localhost:3001/api/channels/$CHANNEL_ID/members" \
-H "Authorization: Bearer $OWNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"pubkeys\": [\"$NO_OWNER_HEX\"], \"role\": \"member\"}" | jq .Expected response (200 OK):
{
"added": [],
"errors": [
{
"pubkey": "<no-owner-hex>",
"error": "policy:owner_only — agent has no owner set"
}
]
}NIP-29 uses signed Nostr events over WebSocket. Kind 9000 (PUT_USER) is the add-member event.
Requirement: You need a Nostr keypair tool to sign events. Options:
A PUT_USER event adds a member to a channel:
{
"kind": 9000,
"pubkey": "<actor-hex>",
"created_at": <unix-timestamp>,
"tags": [
["e", "<channel-id-as-nostr-event-id>"],
["p", "<target-pubkey-hex>"],
["role", "member"]
],
"content": "",
"id": "<event-id>",
"sig": "<signature>"
}The relay message format (NIP-01):
["EVENT", "<subscription-id>", <event-object>]# Connect to relay WebSocket
websocat ws://localhost:3001
# Or with authentication header (if relay requires it)
websocat -H "Authorization: Bearer $ACTOR_TOKEN" ws://localhost:3001# Reset agent to anyone policy first (via REST)
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "anyone"}'Send a signed kind:9000 event from stranger targeting agent. Expected relay response:
["OK", "<event-id>", true, ""]Event is stored and agent is added to channel.
# Set agent to nobody policy
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "nobody"}'Send a signed kind:9000 event from owner targeting agent. Expected relay response:
["OK", "<event-id>", false, "invalid: policy:nobody — this agent has disabled external channel additions"]Verify event NOT stored (see §6.3 for DB query).
# Set agent to owner_only policy
curl -s -X PUT http://localhost:3001/api/users/me/channel-add-policy \
-H "Authorization: Bearer $AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_add_policy": "owner_only"}'Send a signed kind:9000 event from stranger targeting agent. Expected relay response:
["OK", "<event-id>", false, "invalid: policy:owner_only — only the agent owner can add this agent"]Send same event from owner targeting agent. Expected relay response:
["OK", "<event-id>", true, ""]# Agent policy is owner_only from 4.5Send a signed kind:9000 event where actor == target (agent adds itself). Expected relay response:
["OK", "<event-id>", true, ""]Event stored, agent added to channel regardless of policy.
If you have nak installed:
# Generate event and pipe to websocat
nak event \
--kind 9000 \
--tag e="<channel-event-id>" \
--tag p="<agent-hex>" \
--tag role="member" \
--sec "$ACTOR_PRIVKEY_HEX" \
| nak encode \
| websocat ws://localhost:3001The set_channel_add_policy MCP tool is available when the agent is connected via sprout-mcp.
sprout-mcpserver running and connected to an MCP client (e.g., goose)- Agent authenticated with a valid API token
Set to owner_only:
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "owner_only"
}
}Expected tool response:
{
"channel_add_policy": "owner_only",
"agent_owner_pubkey": "<owner-hex>"
}Set to nobody:
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "nobody"
}
}Expected tool response:
{
"channel_add_policy": "nobody",
"agent_owner_pubkey": "<owner-hex>"
}Set to anyone (reset):
{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "anyone"
}
}Expected tool response:
{
"channel_add_policy": "anyone",
"agent_owner_pubkey": "<owner-hex>"
}{
"tool": "set_channel_add_policy",
"arguments": {
"policy": "invite_only"
}
}Expected tool response (error string, not JSON):
Error: invalid policy "invite_only" — must be 'anyone', 'owner_only', or 'nobody'
After calling the MCP tool, confirm the DB was updated:
curl -s http://localhost:3001/api/users/me \
-H "Authorization: Bearer $AGENT_TOKEN" | jq '{channel_add_policy, agent_owner_pubkey}'Direct SQL queries to verify schema and data state.
# Connect to Postgres
docker exec -it sprout-postgres psql -U sprout -d sprout
# Or with the connection URL:
psql "$DATABASE_URL"DESCRIBE users;
-- Look for:
-- agent_owner_pubkey | varbinary(32) | YES | MUL | NULL |
-- channel_add_policy | enum('anyone','owner_only','nobody') | NO | | anyone |-- Replace X'...' with binary representation of hex pubkeys
-- Use UNHEX() for convenience:
SELECT
HEX(pubkey) AS pubkey,
HEX(agent_owner_pubkey) AS agent_owner_pubkey,
channel_add_policy
FROM users
WHERE pubkey = UNHEX('<agent-hex>');Expected result after mint-token --owner-pubkey <owner-hex>:
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
| pubkey | agent_owner_pubkey | channel_add_policy |
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
| <agent-hex> | <owner-hex> | anyone |
+------------------------------------------------------------------+------------------------------------------------------------------+--------------------+
SELECT
HEX(pubkey) AS pubkey,
channel_add_policy,
HEX(agent_owner_pubkey) AS agent_owner_pubkey
FROM users
WHERE pubkey IN (UNHEX('<agent-hex>'), UNHEX('<owner-hex>'), UNHEX('<stranger-hex>'));After a kind:9000 rejection (§4.4, §4.5), confirm the event is absent from the events table:
SELECT id, kind, HEX(pubkey) AS pubkey, created_at
FROM events
WHERE kind = 9000
AND pubkey = UNHEX('<actor-hex>')
ORDER BY created_at DESC
LIMIT 5;
-- Should NOT contain the rejected event ID-- All users should have channel_add_policy = 'anyone' unless explicitly changed
SELECT COUNT(*) AS total_users,
SUM(channel_add_policy = 'anyone') AS anyone_count,
SUM(channel_add_policy = 'owner_only') AS owner_only_count,
SUM(channel_add_policy = 'nobody') AS nobody_count
FROM users;-- Before: agent has owner set
SELECT HEX(pubkey), HEX(agent_owner_pubkey), channel_add_policy
FROM users WHERE pubkey = UNHEX('<agent-hex>');
-- Delete the owner account (simulate owner deletion)
DELETE FROM users WHERE pubkey = UNHEX('<owner-hex>');
-- After: agent_owner_pubkey should be NULL (FK ON DELETE SET NULL)
SELECT HEX(pubkey), HEX(agent_owner_pubkey), channel_add_policy
FROM users WHERE pubkey = UNHEX('<agent-hex>');
-- Expected: agent_owner_pubkey = NULL, channel_add_policy unchanged
⚠️ Warning: This test deletes the owner user row. Use a throwaway keypair for this test and recreate the owner afterwards if needed.
| # | Criterion | Test Section | Pass Condition |
|---|---|---|---|
| AC-1 | Agent with anyone policy can be added by any authenticated user |
§3.2 | added array contains agent pubkey |
| AC-2 | Agent with owner_only policy can be added by its owner |
§3.4 | added array contains agent pubkey |
| AC-3 | Agent with owner_only policy cannot be added by non-owner |
§3.3, §4.5 | errors array contains agent pubkey with policy:owner_only message |
| AC-4 | Agent with nobody policy cannot be added by anyone (self-add still allowed) |
§3.5, §4.4 | errors array contains agent pubkey with policy:nobody message |
| AC-5 | NIP-29 kind:9000 enforces same policy as REST, BEFORE event storage | §4.4, §4.5 | OK false "invalid: policy:..." returned; event absent from DB (§6.4) |
| AC-6 | sprout-admin mint-token --owner-pubkey <hex> sets agent_owner_pubkey in users |
§2.3, §6.2 | DB row has correct agent_owner_pubkey after mint |
| AC-7 | Agent can set own policy via MCP set_channel_add_policy tool |
§5.2 | Tool returns updated policy; DB reflects change (§5.4) |
| AC-8 | Any user can set own policy via PUT /api/users/me/channel-add-policy |
§3.1 | 200 response with updated channel_add_policy |
| AC-9 | Default policy is anyone — no behavior change for existing agents |
§3.2, §6.5 | Existing add flows unaffected; new users default to anyone |
| AC-10 | Owner account deletion sets agent_owner_pubkey = NULL |
§6.6 | After DELETE FROM users WHERE pubkey = owner, agent row has agent_owner_pubkey = NULL |
| AC-11 | owner_only with NULL owner returns policy error |
§3.8 | errors contains "policy:owner_only — agent has no owner set" |
| AC-12 | Self-add bypasses policy for any user type, via both REST and NIP-29 | §3.6, §4.6 | added array contains actor pubkey regardless of policy |
| AC-13 | Batch add with mixed allowed/blocked pubkeys: partial success | §3.7 | added contains allowed pubkeys; errors contains blocked pubkeys with policy messages |
| Value | Who Can Add Agent | Notes |
|---|---|---|
anyone |
Any authenticated user | Default. Backward compatible. |
owner_only |
Only agent_owner_pubkey holder |
NULL owner → effectively nobody |
nobody |
No one (self-add still works) | Private channels inaccessible |
| Method | Path | Auth | Purpose |
|---|---|---|---|
PUT |
/api/users/me/channel-add-policy |
Bearer token | Set caller's channel add policy |
POST |
/api/channels/{id}/members |
Bearer token | Add members (policy enforced per-item) |
POST |
/api/channels/{id}/join |
Bearer token | Self-join open channel (no policy check) |
| Policy | REST error string | NIP-29 rejection string |
|---|---|---|
owner_only (non-owner) |
policy:owner_only — only the agent owner can add this agent |
invalid: policy:owner_only — only the agent owner can add this agent |
owner_only (no owner set) |
policy:owner_only — agent has no owner set |
invalid: policy:owner_only — agent has no owner set |
nobody |
policy:nobody — this agent has disabled external channel additions |
invalid: policy:nobody — this agent has disabled external channel additions |
| DB error | policy lookup failed: <error> |
propagated as ? error |