Files
buzz/docs/SPROUT_LITE_MODE.md
T
Michael Neale 84f84931f1 feat(serverless): run channels, DMs, messages, and agents against a generic Nostr relay
Adds a 'serverless' workspace mode that points the desktop app at any generic
public Nostr relay (e.g. wss://relay.damus.io) with zero Sprout server
infrastructure — no Postgres, no HTTP /query|/events bridge, no NIP-98 auth.
Reuses Sprout's native event kinds (39000 channel metadata, 39002 membership,
kind 9 messages, h-tag scoping); 'serverless' is purely a transport + auth
concern, so the server-mode path is untouched.

Transport: reads/writes go over plain WebSocket (REQ/EOSE, EVENT/OK) instead
of the HTTP bridge; NIP-42 AUTH is answered only if the relay challenges.

Desktop backend:
- AppState.serverless flag + is_serverless(); apply_workspace gains a flag
- ws_relay.rs: query_relay_ws / submit_event_ws / publish_signed_event_ws
- relay.rs: query_relay/submit_event + agent profile sync branch to WS
- events.rs: build_channel_metadata_serverless (39000) + members (39002)
- create_channel/open_dm publish those events directly (DM ids = UUIDv5 over
  sorted participants so both sides converge without a server)
- managed agents inherit SPROUT_SERVERLESS into the ACP subprocess env

Agents (full support, not a follow-up):
- sprout-acp: RestClient + HarnessRelay gain serverless mode; query/submit use
  plain WS, NIP-42 handshake skipped on (re)connect; SPROUT_SERVERLESS env/arg
- sprout-cli: SproutClient serverless WS query/submit; --serverless flag,
  to_ws_url helper, NetworkMsg error variant
- npub respond-to permissions are in-process and work unchanged

Frontend:
- Workspace.mode + workspaceMode()/isServerlessWorkspace() helpers
- relayClient.setServerless() skips the AUTH-wait on connect; late challenges
  still answered so writes succeed on relays that require auth
- ServerlessContext/useIsServerless() hides search, pulse, projects, workflows
- Serverless toggle in the Add Workspace dialog and Welcome screen

Docs: docs/SPROUT_LITE_MODE.md updated with the implementation + testing steps.
2026-06-01 20:42:29 +10:00

13 KiB
Raw Blame History

Sprout Serverless Mode

Status: Implemented (first cut) on branch micn/serverless-mode. Channels, DMs, and messages work against any generic public Nostr relay with zero Sprout server infrastructure. See "Implementation" below for what shipped and "Agents" for the known follow-up.

TL;DR for testing

  1. Build & run the desktop app (just dev).
  2. On the welcome screen (or + Add workspace), tick Serverless mode and enter a public relay, e.g. wss://relay.damus.io or wss://nos.lol.
  3. Create a channel, send messages, open a DM. All of it round-trips through the public relay over plain WebSocket — no Postgres, no /query, no auth server.

Server-only surfaces (search, pulse, projects, workflows) are hidden in this mode. Agents are not yet functional serverless (see below).


Sprout Lite — Serverless Relay Mode

A design note for a "no-server" mode in Sprout, modeled on ../slackest: point the desktop client directly at public (or named) Nostr relays, show channels and DMs, and run with zero Sprout server infrastructure — no sprout-relay, no Postgres, no Redis, no Typesense, no auth/membership.


The two worlds today

Sprout (server mode — what exists now)

The desktop client is thin. It assumes a smart server (sprout-relay) that owns most of the logic:

Concern How Sprout does it today
Transport Native WebSocket via the Tauri Rust backend (invoke, Channel), not a browser socket
Auth NIP-42 AUTH handshake on every connect (relayClientSession.ts); relay rejects unauthed sessions
Channels NIP-29 groups — scoped by h tags, membership enforced server-side (migrations/0001_relay_members.sql, sprout-auth)
Queries Relay p-gate: a REQ with no kinds returns 403 (see AGENTS.md gotchas)
Messages Custom kinds: 9/40002 stream messages, 45001/45003 forum (sprout-core/src/kind.rs, desktop/.../kinds.ts)
Search Server-side Typesense (sprout-search), via NIP-50 search filters
Threads reply_count/descendant_count materialized in Postgres by the relay
Presence/typing Redis pub/sub fan-out (sprout-pubsub)
DMs Routed through relay membership/auth

So "Sprout" = a specific opinionated relay + a client that depends on it.

Slackest (the serverless reference)

../slackest is ~1,200 lines of vanilla TS that talks to any relay:

Concern How Slackest does it
Transport nostr-tools SimplePool directly in the page (browser WS)
Auth None — local nsec, sign-and-publish
Channels NIP-28 (kind 40 create, kind 42 message, e-tag root)
Queries Plain relay REQ with kinds — works on any public relay
Search Client-side only
DMs NIP-04 (kind 4, encrypted)
Identity Generate or import nsec, persisted in Tauri store / localStorage
State All client-side: joined channels, DM contacts, relays, profiles

No server. The relay is a dumb event store. State lives in the client and in whatever public relays you point at.


What "Sprout Lite" means

A mode where Sprout behaves like Slackest: the relay is just a public Nostr relay, all coordination logic moves to (or is skipped in) the client, and none of the sprout-* server crates are involved.

The hard truth up front: Sprout's current channel model (NIP-29 + AUTH + server membership + custom kinds) does not work on a dumb public relay. Lite mode therefore needs a second protocol profile based on open NIPs (NIP-28 channels, NIP-04/NIP-17 DMs, kind 0 profiles) — exactly Slackest's model.

So this is less "flip a flag" and more "add a relay adapter + protocol profile behind the existing client UI."


Sprout already has a workspace concept (features/workspaces/) where each workspace = a relay URL + identity. That's the natural seam. Add a workspace kind:

// features/workspaces/types.ts
type WorkspaceMode = "sprout" | "lite";

type Workspace = {
  id: string;
  name: string;
  relayUrl: string;       // lite: one of several public relays
  relays?: string[];      // lite: a relay set (SimplePool)
  pubkey: string;
  mode: WorkspaceMode;    // NEW
  addedAt: string;
};

Then introduce a client interface that both modes implement, so the React feature hooks don't care which world they're in:

interface SproutClient {
  connect(): Promise<void>;
  disconnect(): void;
  publish(event: UnsignedEvent): Promise<RelayEvent>;
  subscribe(filters: RelaySubscriptionFilter[], onEvent): Subscription;
  queryHistory(filters): Promise<RelayEvent[]>;
  connectionState: ConnectionState;
}
  • RelayClient (existing, relayClientSession.ts) → the sprout impl (Tauri WS + NIP-42 + NIP-29).
  • LiteRelayClient (new) → the lite impl, a thin wrapper over nostr-tools SimplePool, basically Slackest's nostr.ts adapted to the SproutClient interface (no AUTH, no h tags, kinds-only REQs).

useWorkspaceInit.ts picks the implementation based on workspace.mode and stashes it in the same singleton slot the app already uses. Because the app already key-remounts on workspace switch and has a resetWorkspaceState() contract, swapping client implementations per workspace is well-supported — the new lite client just needs its own reset hook registered there.

Why an adapter and not a fork

  • Reuses the entire desktop UI (sidebar, message list, composer, modals, themes) — no second app to maintain.
  • Reuses workspace switching, drafts, profile caches.
  • Keeps the door open to a workspace list that mixes a corporate Sprout relay and public Nostr channels side by side.

Protocol profile for Lite mode

Lite mode speaks open NIPs only (same as Slackest):

Feature Lite kind / NIP Sprout kind (for contrast)
Profile 0 (NIP-01 metadata) 0
Channel create 40 (NIP-28) 39000 channel metadata + NIP-29 group
Channel message 42 (NIP-28), e-tag root 9 / 40002, h-tag
DM 4 (NIP-04) or 1059+14 (NIP-17 gift wrap) server-routed
Reaction 7 (NIP-25) 7
Profile lookup 0 subscription by author server-side

A small kind-mapping layer lets the existing message components render either profile. The renderer cares about {author, text, createdAt, roomKey} — the lite adapter normalizes NIP-28 events into that shape, just like ingestMessage does in Slackest's main.ts.

Channel identity in lite mode is the kind-40 event id, not a name (per Slackest's README) — so the sidebar shows display labels but joins/dedupes on the event id, and the "add channel" modal accepts a pasted channel id.


What you gain and what you lose

Gain

  • Run Sprout against wss://relay.damus.io, wss://nos.lol, etc. with no backend at all.
  • Zero infra to stand up for demos, personal use, or interop testing.
  • Real Nostr interop — channels visible to any NIP-28 client.

Lose (vs. server mode)

  • No membership/access control — public channels are public.
  • No server-side search (client-side only, over what you've fetched).
  • No materialized thread counts, presence fan-out, or read-state sync across devices (could be reintroduced later via NIP-29-capable public relays or client-side computation).
  • No agents/workflows/huddle audio/git hosting — those are sprout-relay features with no dumb-relay equivalent. Lite mode should hide these surfaces rather than break on them.

Implementation sketch (incremental, low-risk)

  1. Add mode to Workspace and default everything existing to "sprout" (workspaceStorage.ts migration — mirror the existing nsec-strip migration pattern).
  2. Vendor nostr-tools into desktop/package.json (it's already used in slackest; Sprout doesn't ship it client-side today).
  3. Add LiteRelayClient in shared/api/ implementing the SproutClient interface over SimplePool. Port the publish/subscribe/DM-decrypt logic from ../slackest/src/nostr.ts.
  4. Extract a SproutClient interface and make RelayClient conform; wire client selection in useWorkspaceInit.ts by workspace.mode.
  5. Add a kind-mapping/normalization layer so message + channel hooks consume a profile-agnostic shape.
  6. Add an "Add public workspace" flow in onboarding/workspace UI: enter a relay set, generate or import nsec, pick/create NIP-28 channels.
  7. Feature-gate server-only surfaces (agents, workflows, huddle, search- server, presence) behind mode === "sprout".
  8. Register the lite client's reset in resetWorkspaceState().

Steps 1–3 are independently shippable and unlock a Slackest-equivalent in a single new workspace, without touching the server-mode path at all.


Quick prototype option

If the goal is just to see it working fast, the lowest-effort path is to keep ../slackest as a separate tiny app and treat it as the lite reference client — it already does exactly this. The doc above is the path to folding that capability into the Sprout desktop app so there's one client with a mode switch, rather than two apps.


Implementation (what shipped on micn/serverless-mode)

We took Option A: same app, same native event kinds (39000 channel metadata, 39002 membership, kind 9 messages, h-tag scoping), just pointed at a generic relay. "Serverless" is a transport + auth concern, not a different protocol. Nothing on the existing Sprout-server path changed.

Rust backend (desktop/src-tauri)

  • AppState.serverless (atomic flag) + AppState::is_serverless(). Set by apply_workspace(relay_url, nsec, serverless, …).
  • ws_relay.rs — query_relay_ws (REQ → collect until EOSE → CLOSE) and submit_event_ws (EVENT → wait for OK). NIP-42 AUTH is answered only if the relay challenges; timeouts degrade to best-effort rather than hard-failing.
  • relay.rs — query_relay / submit_event now branch: serverless → ws_relay::* (plain WS), otherwise → the existing HTTP bridge (/query, /events + NIP-98). Every caller (channels, DMs, messages) is unchanged.
  • events.rs — build_channel_metadata_serverless (kind 39000) and build_channel_members_serverless (kind 39002). In server mode the relay materialises these from command kinds (9007 create, 41010 dm-open); on a dumb relay the client publishes them directly.
  • commands/channels.rs create_channel and commands/dms.rs open_dm branch on is_serverless(): publish 39000 + 39002 directly instead of a server command. DM channel ids are derived deterministically (UUIDv5 over the sorted participant set) so both sides converge without a server assigning one.

Frontend (desktop/src)

  • Workspace.mode: "sprout" | "serverless" (+ workspaceMode() / isServerlessWorkspace() helpers; legacy entries default to sprout).
  • relayClientSession.ts — setServerless(true) makes connect() resolve as soon as the socket opens (no blocking on a NIP-42 challenge). Late challenges are still signed so writes succeed on relays that require auth.
  • useWorkspaceInit.ts — calls relayClient.setServerless() and passes the flag to applyWorkspace() before AppShell preconnects.
  • ServerlessContext + useIsServerless() — feature-gates search (Typesense), pulse, projects (git hosting), and workflows in the UI. They degrade to hidden, not broken.
  • Add-workspace + welcome UIs gained a Serverless mode toggle with a public-relay default (wss://relay.damus.io).

What works serverless today

Channels (create/list/join via 39000+39002), channel messages (kind 9, live + history), DMs (deterministic channel id, 39000+39002), profiles (kind 0), reactions/edits/deletes (standard kinds) — all over plain WS against any relay.


Agents in serverless mode (known follow-up)

Agents launch fine (they're local subprocesses with their own keypair) and the desktop-side attach (kind 39002 membership) works over the serverless WS path. But the agent harness sprout-acp does not yet work against a generic relay: it routes all reads and some writes through the Sprout HTTP bridge (POST /query, POST /events) with NIP-98 + a NIP-OA x-auth-tag, and relies on server-side NIP-29 membership scoping. So an attached agent can't currently discover channels or build prompt context on a dumb relay.

The fix is the same pattern applied here, one layer down: give sprout-acp's RestClient a serverless mode that swaps bridge_post("/query") / ("/events") for plain-WS REQ/EVENT (it already holds a WS connection for the live stream). The npub-permission / respond-to gate is in-process and needs no server, so once the transport is swapped, agent permissions work unchanged. Until then, serverless mode hides nothing about agents but they will be inert in a serverless channel.