Skip to content

Sessions & Multi-User

SLOP’s core protocol describes a single provider serving a single state tree. This works for SPAs (each browser tab is its own provider), CLI tools, and single-user desktop apps. But server-backed web apps serve many users simultaneously, and each user sees different state — different data, different permissions, different active views.

This document is partly descriptive and partly forward-looking: it shows the provider-per-session workaround you can build with the current SDKs, and it sketches the session-aware APIs the SDKs would need to make multi-user support first-class.

Status as of 0.1.0: the shipped SDKs do not yet implement session-aware descriptors, session-filtered refresh(), or framework adapters that automate per-session tree rendering. Today, strict multi-user isolation means authenticating connections in your app/framework and routing each session to its own provider instance.

A naive server-side setup creates one provider shared by all consumers:

const slop = createSlopServer({ id: "my-app", name: "My App" });
slop.register("todos", () => ({
type: "collection",
items: getTodos().map(...) // Whose todos?
}));
attachSlop(slop, httpServer); // All WebSocket consumers share this instance

This breaks in three ways:

The state tree is singular. If User A is on the Inbox view and User B is on Settings, the tree can only represent one of them. The context node might show { user: "alice" }, but there is no mechanism for a second consumer to see { user: "bob" } from the same provider.

refresh() re-evaluates all descriptor functions and broadcasts patches to every connected consumer. There is no way to say “refresh User A’s session only.” A mutation to Alice’s data triggers a re-evaluation and broadcast cycle that also hits Bob’s connection — and since the descriptor functions have no user context, they return the same tree for both.

Descriptor functions are zero-argument closures. They have no way to know which user’s state they should return:

slop.register("todos", () => ({
items: getTodos().map(...) // Global — not scoped to a user
}));

Similarly, affordance handlers receive action parameters but no caller identity:

actions: {
delete: { handler: () => deleteTodo(t.id) } // Who is deleting? No session context.
}
ArchitectureMulti-user?Why
Client-only SPANot a problemEach browser tab runs its own provider instance. The tree is inherently single-user.
Server, single-user (CLI, Electron, local dev)Not a problemOne user, one provider, one tree.
Server, multi-user (production web app)ProblemMultiple users connect to the same server. Each needs their own tree.

For SPAs: each tab creates its own createSlop() instance, assembles its own tree from local state, and communicates with its own extension consumer via postMessage. Two users on two browsers are two completely isolated loops. Nothing in the protocol needs to change.

There are two ways to solve multi-user on the server:

  1. Provider-per-session — each user gets their own SlopServer instance with its own tree, diff engine, and subscription state.
  2. Session-scoped trees — one provider, one engine, but descriptor functions receive a session parameter and the engine renders different trees per consumer.

Both approaches are valid. This section analyzes their tradeoffs.

Each authenticated connection gets a fully independent SlopServer:

10,000 users = 10,000 SlopServer instances
Each instance:
- Tree object (nodes, properties, affordances)
- Descriptor registry (closures)
- Diff engine state (previous tree snapshot)
- Subscription tracker (active subscriptions, filters)
- WebSocket connection(s)

Advantages:

  • Simple. Each instance is self-contained. No shared mutable state, no session routing logic.
  • Fault-isolated. A descriptor function that throws crashes one session, not all of them.
  • Easy cleanup. slop.stop() tears down one session completely.

Limitations:

  • Memory scales linearly. Each instance carries its own tree, diff state, and engine overhead. At 10,000 sessions with a moderately complex tree, memory usage is significant.
  • Shared mutations are expensive. If an admin deletes a shared resource, the app must: find all affected sessions (needs a reverse index), call refresh() on each, and each independently re-evaluates descriptors → diffs → serializes → sends patches. That’s O(affected_sessions) × (evaluate + diff + serialize), with no sharing of work across sessions that would produce identical patches.
  • Tab-closed problem. In the fullstack model where the consumer connects directly to the server, the provider must stay alive even when the browser tab closes (the AI consumer may still be connected). This means SlopServer instances accumulate — you can’t clean them up based on browser disconnection alone. Every session that’s ever been opened stays resident until the consumer disconnects or the session expires.
  • Duplicated work. 8,000 users viewing the same page with the same structure (different data) means 8,000 independent diff engines doing structurally identical work.

Scaling profile:

UsersMemoryCPU on shared mutationVerdict
100FineFineGood fit
1,000NoticeableO(1000) refresh loopsWorkable
10,000HeavyExpensiveStrain
100,000Requires horizontal shardingImpractical single-nodeNot viable

One provider instance with session-aware descriptors. The engine maintains per-session state (cached tree, subscriptions) but shares its infrastructure (tree assembly, diffing, transport management).

10,000 users = 1 SlopServer + 10,000 session contexts
The engine:
- Shared descriptor registry (functions of session → descriptor)
- Per-session: rendered tree cache + subscription state + session context
- Shared: diff algorithm, tree assembly, transport management

Advantages:

  • Memory-efficient. The heavy machinery (engine, diff algorithm, descriptor registry, transport layer) exists once. Per-session overhead is just the cached tree and a lightweight session context object.
  • Shared mutations are natural. The engine already knows which sessions subscribe to what. A mutation triggers re-evaluation of the affected descriptor for each relevant session — but the engine orchestrates this in one pass, not N independent loops.
  • Tab-closed resilience. Session contexts are lightweight — keeping 100,000 of them in memory is trivial compared to 100,000 full SlopServer instances. The consumer stays connected to the server; the session context stays alive; only the ui subtree disappears when the tab closes.
  • Batch optimization. When many sessions share the same tree structure (same page, different data), the engine can batch structural diffs and only vary the data. Provider-per-session can’t do this.

Limitations:

  • More complex internals. The engine must track per-session state, route patches to the right connections, and handle session lifecycle — all within a single process.
  • Shared fault domain. A bug in the engine affects all sessions. There’s no physical isolation between users — isolation is logical.
  • Descriptor API change. Descriptors become (session) => descriptor instead of () => descriptor. The developer always has to think about session context.

Scaling profile:

UsersMemoryCPU on shared mutationVerdict
100MinimalMinimalGood fit
1,000LowBatchableGood fit
10,000ModerateOptimizableGood fit
100,000Manageable with evictionNeeds work partitioningViable

Why session-scoped trees are the default recommendation

Section titled “Why session-scoped trees are the default recommendation”

The fullstack model — where the server keeps the merged tree and the consumer connects directly — requires the provider to stay alive independently of the browser tab. This is the deciding factor:

Consumer ──WebSocket──► Server (owns the full tree)
├── todos (server data)
├── settings (server data)
└── ui (mounted from browser — absent when tab closed)
Tab open: consumer sees todos + settings + ui
Tab closed: consumer sees todos + settings, can still invoke server-side actions

With provider-per-session, keeping providers alive means orphaned SlopServer instances sitting in memory — full engine, full tree, full diff state — for every session, indefinitely. With session-scoped trees, the same scenario costs one lightweight session context per user.

The analogy is how web servers work: one Express/Hono app, many requests, each request gets its own req.user. You don’t spin up a new Express instance per user. The engine is shared; the context varies.

Provider-per-session is still the right choice when:

  • Low session count (< 100 concurrent). The simplicity wins over the efficiency.
  • Fault isolation matters more than memory. Multi-tenant SaaS where one tenant’s buggy data must not crash others.
  • Sessions are short-lived. If users connect, do one thing, and disconnect, the cleanup overhead is minimal.
  • You need horizontal sharding anyway. At massive scale, each shard runs a subset of sessions as independent providers. The per-session model maps cleanly onto this.

The primary concern with session-scoped trees is memory: the engine holds a rendered tree per active session. This section provides concrete numbers.

The engine maintains two copies of the rendered tree per active session — the current tree (for snapshots) and the previous tree (for diffing). Inactive sessions (no consumer connected) only store a lightweight session context.

Active session: rendered tree + previous tree + subscription state ≈ 2× tree size
Inactive session: session context (userId, role, permissions) ≈ 1KB

A SLOP node averages ~500 bytes (id, type, properties, meta, affordances). But a well-implemented provider never renders the full application state — the spec’s scaling features keep per-session trees small:

Scaling featureEffect
View-scoped treesOnly the active view is expanded; inactive views are stubs (~20 bytes each)
Windowed collections25 items inline, not 1,000
Lazy subtreesMessage bodies, attachments, threads — null until queried
Salience filteringLow-salience nodes excluded from subscriptions entirely

A typical provider renders 50–200 nodes per session. That’s 25–100KB per rendered tree, or 50–200KB with the diff snapshot.

Assumes 200KB per active session (100-node tree × 2 for diffing), 1KB per inactive session:

Total sessionsActive (%)Memory (trees)Memory (contexts)Total
1,000100%200MB~200MB
10,00030%600MB7MB~600MB
50,00015%1.5GB42MB~1.5GB
100,00010%2GB90MB~2.1GB

These numbers are for the SLOP tree layer only — the application’s own data (database, caches) is separate and shared.

The single most impactful optimization is not holding rendered trees for idle sessions. If no consumer is connected, the session context stays (it’s 1KB) but the rendered tree is evicted. When a consumer reconnects, the engine re-evaluates descriptors and sends a fresh snapshot.

This means memory scales with concurrent active consumers, not total sessions. An app with 100,000 registered users but 3,000 concurrent AI consumers uses ~600MB for SLOP trees — well within a single server’s capacity.

Memory is rarely the bottleneck. CPU matters when a shared mutation (admin action, broadcast update) triggers re-evaluation across many sessions:

Shared mutation → evaluate descriptor for each affected session → diff each → send patches

This is O(affected_sessions) regardless of approach — provider-per-session does the same work in a loop. Session-scoped trees can batch this more efficiently (shared descriptor registry, no per-instance overhead), but the per-session evaluate+diff cost is inherent.

Mitigations:

  • Scope refreshes narrowly. refresh({ where: s => s.orgId === "acme" }) only touches affected sessions, not all of them.
  • Debounce shared mutations. Batch rapid changes (50–100ms) into one refresh cycle.
  • Offload to workers. Descriptor evaluation and tree diffing are pure functions — they can run in worker threads.

Provider-per-session has the same per-session tree cost, plus ~50–100KB of engine overhead per instance (diff engine, subscription tracker, event system, descriptor registry). At 10,000 active sessions, that’s 500MB–1GB of additional overhead compared to session-scoped trees.

Session-scoped treesProvider-per-session
Per active session~200KB (tree only)~300KB (tree + engine)
Per inactive session~1KB (context)~100KB (idle engine) or 0 (destroyed)
10,000 active~2GB~3GB
Shared mutation CPUSameSame
Engine memoryO(1)O(N)

The difference is meaningful but not dramatic. The dominant cost in both approaches is the rendered trees — and the spec’s scaling features (view scoping, windowing, lazy subtrees) are what keep those small. A provider that uses scaling features well will scale fine with either approach. A provider that dumps 10,000 nodes per session will struggle with both.

The examples in this section show the API shape the SDKs would need for first-class multi-user support. They are not current public APIs.

Descriptor functions receive a session context parameter:

const slop = createSlopServer({ id: "my-app", name: "My App" });
slop.register("todos", (session) => ({
type: "collection",
items: getTodosForUser(session.userId).map(t => ({
id: t.id,
props: { title: t.title, done: t.done },
actions: session.permissions.includes("write")
? {
toggle: () => toggleTodo(t.id),
delete: { handler: () => deleteTodo(t.id), dangerous: true },
}
: {}, // read-only users get no actions
})),
}));
slop.register("context", (session) => ({
type: "context",
props: {
user: session.userName,
role: session.role,
permissions: session.permissions,
},
}));

The session parameter is injected by the engine at evaluation time. Each consumer’s subscription triggers evaluation with that consumer’s session context.

Authentication happens at the WebSocket upgrade — before any SLOP messages flow:

attachSlop(slop, httpServer, {
path: "/slop",
// Called on WebSocket upgrade — return a session or null to reject
authenticate: (req) => {
const token = parseCookie(req.headers.cookie)?.session;
return token ? getSession(token) : null;
},
});

The engine associates the authenticated session with the connection. All subsequent descriptor evaluations for that connection use its session context.

refresh() accepts an optional session filter:

// Refresh one session (e.g., after a user-specific mutation)
slop.refresh({ sessionId: "abc123" });
// Refresh all sessions for a user (e.g., user has multiple tabs)
slop.refresh({ userId: "alice" });
// Refresh all sessions (e.g., after a global config change)
slop.refresh();
// Refresh sessions matching a predicate (e.g., after a shared resource changes)
slop.refresh({ where: (session) => session.orgId === "acme" });

When scoped, only the matching sessions re-evaluate their descriptors, diff, and receive patches. Unaffected sessions are untouched.

Handlers receive the invoking session as a second argument:

slop.register("todos", (session) => ({
type: "collection",
actions: {
add: {
params: { title: "string" },
handler: ({ title }, session) => {
addTodo({ title, userId: session.userId });
// Auto-refresh for this session after handler completes
},
},
},
items: getTodosForUser(session.userId).map(...),
}));

The handler knows who the caller is without protocol changes — the engine injects the session that owns the connection the invoke arrived on.

A single user may have multiple tabs open, each with its own WebSocket connection. These share the same session context and see the same server-side tree.

Each tab’s browser UI provider mounts its own ui subtree:

Session "alice":
├── todos (server data — shared across tabs)
├── settings (server data — shared across tabs)
├── ui/tab-1 (route: /inbox, from Tab 1's browser UI provider)
└── ui/tab-2 (route: /settings, from Tab 2's browser UI provider)

When a tab closes, its ui/tab-N subtree is removed. The server data and other tabs’ UI subtrees remain. The AI consumer still sees the full server-side tree and can continue interacting.

WebSocket connect
authenticate(req) → Session
Engine associates session with connection
Normal SLOP flow: hello → subscribe → snapshot → patch...
WebSocket disconnect
If no more connections for this session:
→ keep session context alive (lightweight — just a data object)
→ evict after session expiry (tied to auth session TTL)
→ or evict after idle timeout (no consumer reconnection within N minutes)

Because session contexts are lightweight (a plain object with user ID, role, permissions), they can stay in memory far longer than a full SlopServer instance would.

For apps with many sessions, the engine should support eviction:

  • TTL-based — evict session contexts when the auth session expires.
  • Idle-based — evict when no consumer has been connected for a threshold period.
  • LRU — cap the number of active session contexts and evict least-recently-used when the cap is reached. Evicted sessions re-authenticate and re-subscribe on next connection.

Eviction only removes the cached tree and session context. The user’s data in the database is unaffected. Reconnecting triggers a fresh descriptor evaluation and snapshot.

For apps that choose provider-per-session, the pattern is straightforward:

function createSessionProvider(session: Session) {
const slop = createSlopServer({
id: `my-app-${session.id}`,
name: "My App",
});
// Descriptors close over the session — zero-argument, session is in the closure
slop.register("todos", () => ({
type: "collection",
items: getTodosForUser(session.userId).map(t => ({
id: t.id,
props: { title: t.title, done: t.done },
actions: session.permissions.includes("write")
? { toggle: () => toggleTodo(t.id) }
: {},
})),
}));
return slop;
}
import { WebSocketServer, WebSocket } from "ws";
import type { Connection, SlopServer } from "@slop-ai/server";
const sessions = new Map<string, SlopServer>();
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const session = authenticate(req);
if (!session) { socket.destroy(); return; }
let slop = sessions.get(session.id);
if (!slop) {
slop = createSessionProvider(session);
sessions.set(session.id, slop);
}
wss.handleUpgrade(req, socket, head, (ws) => {
const conn: Connection = {
send(message) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
},
close() {
ws.close();
},
};
slop.handleConnection(conn);
ws.on("message", (data) => {
slop.handleMessage(conn, JSON.parse(data.toString()));
});
ws.on("close", () => {
slop.handleDisconnect(conn);
});
});
});

An attachSlopWithSessions()-style helper would be a reasonable future addition, but no such wrapper ships today. Right now the session-to-provider map, idle cleanup, and authentication glue live in application code.

Shared mutations require iterating affected sessions:

app.delete("/api/shared-resource/:id", (req, res) => {
const affectedUserIds = deleteSharedResource(req.params.id);
for (const [sessionId, slop] of sessions) {
if (affectedUserIds.includes(getSessionUser(sessionId))) {
slop.refresh();
}
}
res.json({ ok: true });
});

This is the main ergonomic cost of provider-per-session — the app must maintain a reverse index from users to sessions, and fan out refreshes manually.

No shipped framework adapter automates session scoping yet.

  • @slop-ai/tanstack-start composes a server-owned provider with browser-owned ui state and wires automatic refresh after server mutations, but multi-user session isolation is still application-owned.
  • @slop-ai/server/nitro and @slop-ai/server/vite expose transport helpers for Nitro and Vite integrations; they do not create per-session providers or inject session context into descriptors.
  • For multi-user apps today, authenticate in framework middleware or the WebSocket upgrade path and route each session to its own provider instance.

The current framework-facing building blocks are:

  • @slop-ai/tanstack-start for full-stack TanStack Start apps
  • @slop-ai/server/nitro for custom Nitro apps
  • @slop-ai/server/vite for custom Vite integrations

A future session-aware adapter layer should sit on top of those packages, keep the protocol unchanged, and hide the provider-per-session boilerplate. It should not rely on separate framework packages that do not exist in the repo today.

The core SLOP protocol requires no changes for multi-user support. Sessions are an application concern, not a protocol concern:

Protocol layerMulti-user impact
MessagesUnchanged — subscribe, snapshot, patch, invoke work per-connection
State treeUnchanged — the tree structure is the same whether rendered for one user or many
TransportUnchanged — each WebSocket connection is already independent
DiscoveryMinor — /.well-known/slop describes the app, not a specific session. Authentication happens at WebSocket upgrade.

The hello message may optionally include session metadata:

{
"type": "hello",
"provider": {
"id": "my-app",
"name": "My App",
"slop_version": "0.1",
"capabilities": ["state", "patches", "affordances"],
"session": { // Optional, informational
"user": "alice",
"role": "admin"
}
}
}

This is not a protocol requirement — it’s a convenience for consumers that want to display or log session context. The session field is opaque to the protocol.

From the consumer’s perspective, nothing changes. It connects to a WebSocket endpoint, receives a hello, subscribes, and gets a tree. Whether that tree is session-scoped or global is invisible — the consumer just sees its state tree.

When the browser tab closes, the consumer retains access to the server-side tree. It can still invoke server-side affordances (add_todo, toggle, delete). Only browser-specific state (DOM-level UI, client-side filters, compose drafts) disappears — that state was in the ui subtree mounted from the browser, which unmounts when the tab closes.

The consumer should not assume it can see other users’ state. If it needs to act on behalf of multiple users, it needs multiple connections (one per session), each authenticated separately.

ConcernSession-scoped treesProvider-per-session
Memory at scaleO(1) engine + O(N) lightweight contextsO(N) full engine instances
Shared mutationsEngine orchestrates in one passApp fans out N independent refreshes
Tab-closed resilienceTrivial — contexts are lightweightExpensive — full instances stay alive
Fault isolationLogical (shared process)Physical (independent instances)
Descriptor API(session) => descriptor() => descriptor (session in closure)
ComplexityEngine is more complexEngine is simple, app routing is more complex
Best forProduction multi-user appsSmall-scale, high-isolation, short-lived sessions
  • Session isolation is mandatory. A consumer connected to Alice’s session must never see Bob’s tree or invoke Bob’s affordances. With session-scoped trees, the engine enforces this by evaluating descriptors with the correct session context per connection. With provider-per-session, isolation is structural — separate instances share nothing.

  • Authentication happens at the transport level. The WebSocket upgrade request carries credentials (cookies, tokens). SLOP messages do not include authentication — by the time messages flow, the connection is already authenticated and bound to a session.

  • Affordance handlers must validate authorization. In both approaches, the handler knows the session (via parameter or closure). But affordances appearing in the tree is not sufficient authorization — the handler should still validate that the mutation is permitted, just as any API endpoint would. An affordance being visible is a UX signal, not a security boundary.

  • Cross-session mutations require explicit design. When a mutation affects other users (shared resources, admin actions), the app must explicitly trigger refresh for affected sessions. Implicit cross-session state leakage is a security risk. With session-scoped trees, use refresh({ where: ... }). With provider-per-session, iterate affected instances.