Skip to Content
Self-hostingConfigurationLocal (single-user) build

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_member collapse 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_read tables 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 but 1) keeps the full hosted multi-user app, byte-for-byte unchanged; 1 selects the local build. It is NEXT_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.ts

PGlite 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:

apps/desktop/desktop.env
# 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-webview

pnpm --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 build

The 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.

Last updated on