Local (single-user) build
Screenplay ships in two shapes from one codebase: the hosted, multi-tenant web app and a single-user desktop build that runs entirely on one person’s machine (#404). The desktop build swaps each cloud backend for a local one behind the seams the codebase already exposes — see Sandbox provider, Yjs host, Blob store — and, on top of those, excludes the entire multi-user surface.
The multi-user surface
The hosted app is multi-tenant: GitHub OAuth login, room_member sharing, Yjs
awareness/presence, and thread/comment co-view. None of that makes sense for
a solo tool running on your own machine, so the desktop build leaves it out:
- No login screen. The app opens straight into the work as a single seeded
local user (
apps/app/lib/local-user.ts). GitHub OAuth and its tables (session/account/verification) are excluded. - No membership or sharing.
canAccess/room_membercollapse to that one user — every canvas belongs to it — and the Share affordances are hidden. - No persisted comments or co-view. The
thread/comment/thread_readtables and the collaborative thread UI (canvas pins, replies, read-state, the player comment feed) are excluded. Reply in chat on selected document text is single-user and kept: it quotes the passage into the chat composer and persists nothing. - No remote presence. With a single local Yjs peer there are no other cursors to show; the follow toolbar is hidden. (The awareness plumbing the collaborative editor needs stays — there is simply nobody else on it.)
Git operations use the host’s own credentials, so the brokered GitHub token and ADR 0002’s egress firewall trust boundary dissolve. Re-enabling multi-tenant operation on the local build is out of scope.
The switch
One build-time flag gates the whole surface, a sibling of the per-seam backend
flags (SANDBOX_BACKEND, SCREENPLAY_DB, NEXT_PUBLIC_YJS_HOST):
NEXT_PUBLIC_SCREENPLAY_LOCAL— unset (or anything but1) keeps the full hosted multi-user app, byte-for-byte unchanged;1selects the local build. It isNEXT_PUBLIC_so the same constant is inlined into both the server runtime (auth/session, access checks) and the client bundle (the affordances that simply don’t render), and so the bundler dead-code-eliminates the unused branch.
Read it through apps/app/lib/local-mode.ts’s isLocalBuild, never
process.env directly, so the gate stays one well-named seam.
Database tables
The excluded tables aren’t merely unused on the desktop build — they are never
created. The Drizzle schema is split into apps/app/lib/db/schema-core.ts (the
surviving tables: user, kv_store, room, the agent_* log, terminal_tab)
and apps/app/lib/db/schema-multiuser.ts (auth, room_member, comments). The
hosted build uses the full schema and migration history (drizzle/); the desktop
PGlite backend migrates from the core half alone (drizzle/local, generated by
drizzle.local.config.ts). The single local user is seeded on boot from
instrumentation.ts.
When you change schema-core.ts, generate the desktop migration alongside
the hosted one, from apps/app:
pnpm drizzle-kit generate --config drizzle.local.config.tsPGlite applies drizzle/local itself on boot, so there’s no migrate step.
Building it by hand
The desktop profile, apps/desktop/desktop.env, sets every flag together:
# Read at build time
SCREENPLAY_DESKTOP=1
NEXT_PUBLIC_SCREENPLAY_LOCAL=1
NEXT_PUBLIC_YJS_HOST=local
NEXT_PUBLIC_BASE_PATH=
# Read at runtime, and at build time so both agree
SANDBOX_BACKEND=local
SCREENPLAY_DB=pglite
BLOB_STORE=local-fs
AGENT_ENGINE=external
THUMBNAIL_CAPTURER=tauri-webviewpnpm --filter desktop build:sidecar builds the app with that profile. To do
the same yourself, run next build directly rather than the app’s build
script, which first migrates a hosted Postgres. The build opens PGlite once,
so point it at a throwaway folder:
cd apps/app
SCREENPLAY_DESKTOP=1 \
NEXT_PUBLIC_SCREENPLAY_LOCAL=1 \
NEXT_PUBLIC_YJS_HOST=local \
NEXT_PUBLIC_BASE_PATH= \
SANDBOX_BACKEND=local \
SCREENPLAY_DB=pglite \
BLOB_STORE=local-fs \
AGENT_ENGINE=external \
THUMBNAIL_CAPTURER=tauri-webview \
PGLITE_DATA_DIR="$(mktemp -d)" \
npx next buildThe desktop shell sets the runtime paths and ports (PORT, the data folders,
TAURI_CONTROL_URL) at each launch. Each flag and path is described in
Environment variables.