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.
13 KiB
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
- Build & run the desktop app (
just dev). - On the welcome screen (or + Add workspace), tick Serverless mode and
enter a public relay, e.g.
wss://relay.damus.ioorwss://nos.lol. - 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."
Recommended approach: Adapter behind the Workspace abstraction
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 overnostr-toolsSimplePool, basically Slackest'snostr.tsadapted to theSproutClientinterface (no AUTH, nohtags,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-relayfeatures with no dumb-relay equivalent. Lite mode should hide these surfaces rather than break on them.
Implementation sketch (incremental, low-risk)
- Add
modetoWorkspaceand default everything existing to"sprout"(workspaceStorage.tsmigration — mirror the existingnsec-strip migration pattern). - Vendor
nostr-toolsintodesktop/package.json(it's already used in slackest; Sprout doesn't ship it client-side today). - Add
LiteRelayClientinshared/api/implementing theSproutClientinterface overSimplePool. Port the publish/subscribe/DM-decrypt logic from../slackest/src/nostr.ts. - Extract a
SproutClientinterface and makeRelayClientconform; wire client selection inuseWorkspaceInit.tsbyworkspace.mode. - Add a kind-mapping/normalization layer so message + channel hooks consume a profile-agnostic shape.
- Add an "Add public workspace" flow in onboarding/workspace UI: enter a
relay set, generate or import
nsec, pick/create NIP-28 channels. - Feature-gate server-only surfaces (agents, workflows, huddle, search-
server, presence) behind
mode === "sprout". - 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 byapply_workspace(relay_url, nsec, serverless, …).ws_relay.rs—query_relay_ws(REQ → collect until EOSE → CLOSE) andsubmit_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_eventnow 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) andbuild_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.rscreate_channelandcommands/dms.rsopen_dmbranch onis_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 tosprout).relayClientSession.ts—setServerless(true)makesconnect()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— callsrelayClient.setServerless()and passes the flag toapplyWorkspace()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.