Files
buzz/REPORT.md

16 KiB
Raw Permalink Blame History

Serverless Mode — Design Report

Branch: micn/serverless-mode Status: feature-complete, verified end-to-end against live public relays.

This report explains what serverless mode is, the design decisions behind it, how each piece works, and the evidence that it works end-to-end without affecting the existing server-bound ("sprout relay") behaviour.


1. What it is

Serverless mode (a.k.a. "sprout-lite") lets the Sprout desktop app and the agent harness talk directly to generic public Nostr relays with no Sprout infrastructure — no sprout-relay, no Postgres, no Redis, no Typesense, no auth/membership server. It is a degraded subset of Sprout: channels, DMs, private channels, messages, threading, and agents — all expressed as plain Nostr events over standard relays.

A workspace is either:

  • sprout mode — talks to a Sprout relay via the HTTP bridge (/query, /events) + NIP-98 auth + NIP-42 AUTH WebSocket. (Unchanged, the default.)
  • serverless mode — talks to a comma-separated list of public relays over plain WebSocket (NIP-01 REQ/EVENT/EOSE/OK). No AUTH required.

Default relays when you enable serverless: wss://relay.damus.io, wss://nos.lol, wss://relay.nostr.band.


2. Guiding design decisions

2.1 "It's still relays at the end of the day"

The core insight that shaped everything: serverless is the same product with a different transport. The agent's brain (respond gate, threading, prompt rendering, reply mechanism) and the desktop's feature surface are shared code. Only the bottom transport layer swaps:

Concern sprout mode serverless mode
Respond gate (owner-only default, allowlist, siblings) shared shared
Threading decision (NIP-10) shared shared
Prompt rendering / reply via sprout CLI shared shared
Transport HTTP bridge + NIP-98 + NIP-42 plain WS, multi-relay
Channel discovery one relay (HTTP) multi-relay union
Reply / message publish one relay multi-relay, first-accepts
Membership push (kind 44100) relay side-effect periodic re-discovery

Everything new is gated behind is_serverless() / if serverless. Server mode runs the exact same code paths it always has.

2.2 The standard Nostr client model (no exotic tricks)

Relays do not gossip — an event stored on relay B is invisible to a subscriber on relay A. Every production Nostr client therefore:

  1. publishes to many relays (succeed if any accepts), and
  2. subscribes to many relays and dedups by event id.

This is exactly what damus (RelayPool.swift), nostr-tools (SimplePool), and 0xchat do. Serverless mode follows the same model. There is nothing bespoke.

2.3 Model the operations as Nostr events, not new endpoints

Per the project's "prefer Nostr events over new REST endpoints" rule, serverless membership/channel operations are done by writing the same addressable events the relay would have produced (kinds 39000 / 39002), rather than inventing new flows. The server's command kinds (9007 create-channel, 9021 join, 44100 member-added, …) are server side-effects that a dumb relay never executes, so serverless writes the resulting state events directly.

2.4 nostr-relay-pool for the agent, hand-rolled pool for the desktop

The agent (sprout-acp, a standalone binary) uses the official nostr-relay-pool crate (rust-nostr SDK) for its relay layer — free auto-reconnect, auto-resubscribe, and dedup. The desktop deliberately keeps its own hand-rolled connection pool (ws_pool.rs) to avoid pulling a second TLS/crypto stack into the Tauri process (which already runs aws-lc-rs rustls for reqwest/media). Two rustls providers in one process risk a CryptoProvider install panic. The desktop pool is serverless-only and already self-heals, so the agent's old missed-message bug does not apply there. This asymmetry is documented in ws_pool.rs.


3. How it works, piece by piece

3.1 Event kinds used

All standard Sprout kinds (defined in sprout-core/src/kind.rs):

  • 39000 — channel metadata (addressable; d = channel id, name, t, public/private).
  • 39002 — channel members (addressable; p tags, role as the 4th tag element: ["p", <pubkey>, "", "owner"|"member"]).
  • 9 (KIND_STREAM_MESSAGE) — a channel message, scoped by the h tag (NIP-29 group tag).
  • 1059 — NIP-59 gift wrap (encrypted DMs / private channels).
  • 20002 — typing indicator (ephemeral).
  • 5 — NIP-09 deletion (used for channel delete, since the dumb relay won't process a delete command).

3.2 Channels & membership (desktop)

  • Create (commands/channels.rs): publish 39000 (metadata) + 39002 (members, creator recorded as owner) directly to all relays. The ChannelInfo is built locally and returned immediately — no re-query race (this fixed the early "nothing happened" bug).
  • Join / leave / add / remove: read the current 39002, modify the p list (preserving roles), and re-publish (read-modify-write). NIP-29 command kinds are no-ops on a dumb relay, so direct state writes are the only thing that works.
  • get_channels: in serverless, skips the server's limit:5000 network discovery (which pulled hundreds of unrelated public channels) and scopes to the user's own membership.
  • Critical fix: nostr 0.44 strips self-p tags unless .allow_self_tagging() is set. Without it, the creator's own membership tag was dropped, so get_channels (#p:[me]) found nothing. All serverless membership builders set .allow_self_tagging().

3.3 DMs & private channels (encryption)

  • DMs: open_dm derives a deterministic UUIDv5 channel id from the two pubkeys (so both sides converge on the same id), then publishes 39000 + 39002.
  • Encryption (NIP-17 / NIP-59): a DM or private channel message is built as a normal kind-9 rumor (unsigned, carries the h tag), then gift-wrapped (kind 1059) once per recipient — every channel member plus self. Only the gift wraps hit the relay; the plaintext kind-9 never does. The h tag lives inside the encrypted rumor, so a relay query by #h returns nothing — the channel itself is hidden.
  • Threaded encrypted replies (the privacy fix in this branch): replies in a private channel are also gift-wrapped, with the NIP-10 thread e tags placed inside the rumor. Previously replies fell through to the plaintext path and leaked the reply content to public relays. Now threading is preserved and encrypted. The thread root is resolved locally from already-decrypted messages (the parent rumor isn't queryable in plaintext on the relay).

3.4 Multi-relay transport

  • Desktop (ws_pool.rs + ws_relay.rs): one long-lived WebSocket per relay, multiplexing all queries/publishes. Reads fan out and merge + dedup by event id; writes fan out and succeed if any relay accepts (with a retry if all rate-limit). Dropped connections are detected and reconnected on next use. This fixed the rate-limit storm (the original code opened a fresh socket per op) and the "message shows then disappears" split-brain (publish landed on relay B, live-read was on relay A).
  • Agent (serverless_relay.rs wrapping nostr-relay-pool): a persistent multi-relay subscription with built-in auto-reconnect + auto-resubscribe. On reconnect the SDK re-sends the REQ, and the relay replays its stored matching events — so a message that arrived during a blip is recovered. Events are merged and deduped across relays automatically.

3.5 Agents (the headline capability)

Agents work the same as in server mode — the permission model is identical; only the transport differs.

  • Launch: managed agents launch with the current workspace relay list and SPROUT_SERVERLESS=true. A startup gate (workspace_applied) makes agent restore wait (up to 15s) for the frontend to apply the workspace, so agents never launch with stale relays or in the wrong mode.
  • Serverless detection: the agent infers serverless from a comma-list relay URL (SPROUT_RELAY_URL=wss://a,wss://b) in both sprout-acp and the sprout CLI, so even the agent's reply (sprout messages send) takes the serverless path.
  • Discovery: the agent finds channels it belongs to by querying 39002 (#p = agent) across all relays. Because a dumb relay never emits the membership-added push (kind 44100), the agent re-runs discovery every 20s and subscribes to any newly-joined channels.
  • Receiving messages:
    • Public channels: live REQ for kind 9 scoped by #h = channel.
    • Private channels / DMs: a gift-wrap inbox subscription (kind 1059, #p = agent). On each wrap the agent decrypts to the inner rumor, reads its h tag, and routes it to the right channel.
  • The respond gate (unchanged from server mode): default owner-only — the agent only acts on messages from its owner (the npub that added it, resolved from the SPROUT_AUTH_TAG NIP-OA attestation), plus "siblings" (other agents launched by the same owner). Other modes: allowlist, anyone, nobody. The gate runs in the harness before the LLM sees the event.
  • Replying: the agent posts its reply via the sprout messages send CLI (multi-relay, first-accepts). It addresses the asker by @name in the channel — it does not DM the owner. Threading mirrors the trigger: a reply to a top-level message is top-level; a reply inside a thread uses --reply-to and stays threaded.
  • Working cues: while a turn is in flight the agent publishes a typing indicator (kind 20002, every 3s) and a 💬 reaction, so the UI shows "working…" during the (3090s) LLM latency.

4. Bugs found and fixed on this branch

In rough order, each reproduced and then fixed (and most now covered by a test):

  1. "Join did nothing" — membership via server command kinds is a no-op on a dumb relay → switched to direct 39002 read-modify-write; then .allow_self_tagging().
  2. Rate-limit storm — fresh socket per op → persistent connection pool.
  3. "Message shows then disappears" — split-brain reads → multi-relay read/write/subscribe.
  4. Query floodlimit:5000 discovery pulled the whole public network → skip in serverless.
  5. Channel delete no-op → NIP-09 kind-5 deletion.
  6. Agent DNS crash — comma-list fed to a single connect → multi-relay connect.
  7. Agent didn't see new channels (no 44100 push) → 20s re-discovery.
  8. Agent reconnect storm — a paid relay's auth-required → drop that sub, don't reconnect-loop; removed paid relays from defaults.
  9. Agent launched in server mode / stale relaysworkspace_applied gate + serverless inference from comma-list.
  10. Agent reply lost to rate-limit → multi-relay fan-out + backoff retry.
  11. Agent relay layer fragile (missed messages on relay drop) → replaced with nostr-relay-pool (auto-reconnect/resubscribe/dedup).
  12. 💬 working-cue never landed (500ms timeout too tight for a real WS round-trip) → 4s.
  13. Encrypted replies leaked plaintext → gift-wrap replies too, NIP-10 tags inside the rumor.

5. Verification — evidence it works end-to-end

All run on micn/serverless-mode at the time of this report.

Automated

  • Unit tests: sprout-acp 272 , sprout-cli 114 , sprout-sdk 138 , desktop backend 427 (all pass).
  • Clippy: clean on every touched crate (acp, cli, sdk, desktop backend).
  • End-to-end against live public relays (crates/sprout-acp/tests/e2e_agent_responds.rs, runs the real sprout-acp + real sprout CLI, no LLM stub):
    • agent_responds_in_channel_e2e — proves: receive @mention → reply #1 → typing indicator (kind 20002) during the turn → second message → reply #2 (cancel/redispatch path).
    • agent_responds_in_private_channel_e2e — proves: a gift-wrapped (kind 1059) message is received, decrypted, routed by the inner h tag, gated, and replied to.
    • Both pass reliably (run repeatedly) even while damus returns 503 — the multi-relay fan-out fails over to another relay.
  • Encryption unit test (desktop/src-tauri/src/encrypted.rs): a threaded reply in a private channel is gift-wrapped (kind 1059), the plaintext never appears on the wire, and the NIP-10 root/reply tags survive decryption.

Manual (live)

  • Confirmed on the relays directly that a user's private channels show zero plaintext kind-9 and only un-h-tagged gift wraps.
  • Confirmed goose replies in public channels live (addresses the user by display name).

6. Will it break main (server mode)?

No. Risk is contained:

  • Every serverless behaviour is gated behind is_serverless() (desktop) or if serverless / Option<ServerlessRelay> being None (agent). The ServerlessRelay/nostr-relay-pool path is only constructed inside if serverless. Server mode runs the original code paths unchanged.
  • New modules (ws_pool.rs, ws_relay.rs, encrypted.rs, serverless_relay.rs, ServerlessContext.tsx, defaultRelays.ts) are reachable only from serverless branches.
  • The single shared, non-gated change is REACTION_TIMEOUT (500ms → 4s). It only makes a best-effort reaction more tolerant; server mode's HTTP bridge responds well under 4s, so it cannot regress server behaviour.
  • Dependency additions (nostr-relay-pool, rustls feature on the CLI) resolve to the same nostr 0.44.3 already in the tree — no version conflict.
  • The one failing workspace unit test (git-sign-nostr::test_parse_envelope_rejects_invalid_oa_pubkey) also fails on main — it is pre-existing and unrelated to this branch.

7. Known limitations / follow-ups

  • Public relays are unreliable stores. They rate-limit, 503, and may not retain low-traffic events indefinitely. The multi-relay fan-out + auto-reconnect mitigates this, but serverless is inherently best-effort — appropriate for a "lite" mode, not a system of record.
  • Profile/display names depend on the relays carrying kind-0 metadata; absent that, the agent may address users by raw npub.
  • Desktop ws_pool.rs is hand-rolled (by deliberate choice, §2.4). If the desktop ever shows a concrete live-subscription recovery bug, that targeted path can be hardened without adopting a second TLS stack.
  • Server-mode integration suite (just test, needs Postgres + Redis) was not run for this report; the serverless changes are gated so server paths are unaffected, but running it is the recommended final gate before merge.

8. Key files

Area File
Serverless detection / mode desktop/src-tauri/src/app_state.rs, commands/workspace.rs
Desktop relay transport desktop/src-tauri/src/relay.rs, ws_relay.rs, ws_pool.rs
Channels / DMs / messages desktop/src-tauri/src/commands/{channels,dms,messages}.rs
Encryption (gift wrap) desktop/src-tauri/src/encrypted.rs, commands/encrypted.rs
Agent relay (SDK pool) crates/sprout-acp/src/serverless_relay.rs, relay.rs
Agent main loop / re-discovery / gate crates/sprout-acp/src/lib.rs
Agent + CLI serverless detection crates/sprout-acp/src/config.rs, crates/sprout-cli/src/lib.rs
CLI multi-relay client crates/sprout-cli/src/client.rs
Shared event builders crates/sprout-sdk/src/builders.rs, desktop/src-tauri/src/events.rs
Frontend serverless UI desktop/src/features/workspaces/*
E2E proof crates/sprout-acp/tests/e2e_agent_responds.rs
Deeper implementation map docs/SPROUT_LITE_MODE.md