A Next.js app that runs coding agents inside live development sandboxes with real-time collaboration on a shared canvas.
Before deploying, create accounts and projects for each of the following:
| Service | Used for | Where |
|---|---|---|
| Vercel | Hosting the Next.js app. Also the default sandbox provider — each workspace gets its own @vercel/sandbox VM. Any sandbox backend works; see "Using a different sandbox provider" below to swap it. |
https://vercel.com |
| GitHub OAuth App | Sign-in + repo scope so the app can clone private repos and push commits on the user's behalf |
https://github.com/settings/developers |
| Postgres | Better Auth user/session storage, per-user organization state, the kv_store table used by lib/kv (cached agent/env IDs, encrypted workspace env vars, distributed locks), project rooms + members (room, room_member), and comment threads (thread, comment). Any Postgres works — the default factory in lib/db/neon.ts uses Neon's serverless HTTP driver, but you can swap in postgres-js, node-postgres, or any other Drizzle Postgres driver (see "Using a different Postgres driver" below). |
anywhere you like — Neon, Vercel Postgres, Supabase, a self-hosted server, etc. |
| Yjs host | Durable storage and realtime sync for the per-room Yjs document that holds canvas state (workspaces, agents, artboards, text layers, chat sessions, plans), agent stream events, and Yjs awareness (cursors, viewport, selections). Any Yjs-compatible backend works — the default implementation targets Liveblocks via lib/yjs-host/liveblocks-server.ts (server) + lib/yjs-host/liveblocks-client.tsx (React client), each fronted by a thin re-export (lib/yjs-host/index.ts and lib/yjs-host/client.tsx) that makes the swap a one-line change. Dropping in Hocuspocus, y-websocket, Cloudflare Durable Objects, etc. means adding sibling *-server.ts / *-client.tsx files and pointing those two re-exports at them. |
anywhere you like — Liveblocks, Hocuspocus, y-websocket, Cloudflare Durable Objects, a self-hosted server, etc. |
| Anthropic API | Powers the in-sandbox coding agent (Claude) via @anthropic-ai/sdk |
https://console.anthropic.com |
Auth is handled by Better Auth with GitHub as the only provider. The sandbox clones repos and pushes commits using the OAuth access token Better Auth stores in the account table (keyed by providerId = 'github').
- Create a new OAuth App at https://github.com/settings/developers.
- Set the Authorization callback URL to
$BETTER_AUTH_PRODUCTION_URL/api/auth/callback/github(e.g.https://build.screenplay.space/api/auth/callback/github). Preview deploys route through this same callback via Better Auth'soAuthProxyplugin, then bounce back to the preview URL — one OAuth app is enough for production and every preview. - Copy the Client ID + Secret into
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET. - The app requests
repo,read:user, anduser:emailon first sign-in — no extra GitHub-side config needed.
Because the oAuthProxy plugin signs state on production and verifies it on the preview deploy that started the sign-in, production and every preview deployment must share the same BETTER_AUTH_SECRET. Set it in Vercel with the env scope set to "Production, Preview, and Development" so the value stays in sync everywhere. The same goes for GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET and BETTER_AUTH_PRODUCTION_URL.
For local development you have two choices:
- Option A — share the production secret (simplest). Copy
BETTER_AUTH_SECRET,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET, andBETTER_AUTH_PRODUCTION_URLintoapps/web/.env.local. Sign-ins locally take a detour through the production callback and bounce back tolocalhost. - Option B — dev-only OAuth app. Create a second OAuth app with callback
http://localhost:3000/api/auth/callback/github. In.env.localsetBETTER_AUTH_PRODUCTION_URL=http://localhost:3000, the dev app's client id / secret, and any randomBETTER_AUTH_SECRET— the proxy is a no-op becausecurrentURL === productionURL, so GitHub redirects straight tolocalhostand no production secret ever touches your machine.
- Provision a Postgres database anywhere (Neon, Vercel Postgres, Supabase, a self-hosted server — anything) and copy the connection string into
DATABASE_URL. The default build targets Neon's serverless HTTP driver; see Using a different Postgres driver if your provider doesn't speak that protocol. - That's it. Checked-in SQL migrations under
apps/web/drizzle/are applied automatically at build time — theapps/webbuildscript isdrizzle-kit migrate && next build, so every Vercel deploy lands any new migrations before starting the app.drizzle-kit migrateis idempotent (skips migrations already recorded in__drizzle_migrations).
Schema lives in apps/web/lib/db/schema.ts:
- Better Auth's
user/session/account/verificationtables, plus a per-userorganizationJSONB column for folders/pins. kv_store— backslib/kv(TTL-aware key/value with distributed locks).room/room_member— project rooms and access control. Source of truth for who can open a canvas; the/api/yjs/authroute gates Yjs-host token issuance againstroom_member.thread/comment— canvas comment threads. Realtime fanout rides ameta.commentsRevisioncounter inside the room's Y.Doc — server bumps it after any thread/comment change, clients subscribe viauseCommentsRevisionand refetch.
# 1. Edit apps/web/lib/db/schema.ts
# 2. Generate the migration (SQL file under apps/web/drizzle/)
cd apps/web && pnpm db:generate
# 3. Commit the generated .sql file alongside the schema change
# 4. The next deploy applies it via `drizzle-kit migrate`For throwaway local experiments you can still use pnpm db:push to skip the migration file and sync the schema directly — don't commit the result.
Note on preview deploys: by default every preview deploy runs
drizzle-kit migrateagainst whateverDATABASE_URLis set to in Vercel's Preview scope. If you point preview at the same DB as production, a preview build will apply pending migrations to prod before the PR merges. If you need isolation, point previews at a separate database — e.g. Neon's Vercel integration gives you a per-preview branch automatically.
apps/web/lib/db/index.ts picks the default driver via createNeonDb() in neon.ts. The exported db is typed as the driver-agnostic DB alias (PgDatabase<PgQueryResultHKT, typeof schema>), so any Drizzle Postgres driver is a drop-in replacement. To switch:
-
Install the driver package you want (
postgres,pg,@vercel/postgres, …). -
Add a sibling factory — e.g.
apps/web/lib/db/postgres-js.ts:import postgres from "postgres" import { drizzle } from "drizzle-orm/postgres-js" import * as schema from "./schema" import type { DB } from "./types" export function createPostgresJsDb(): DB { if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set") return drizzle(postgres(process.env.DATABASE_URL), { schema }) }
-
Change the single import in
index.tsto point at your new factory.
lib/kv uses the same db, so nothing else in the app needs to change.
Each workspace runs its coding agent and dev server inside a live sandbox VM. The default backend is @vercel/sandbox via apps/web/lib/sandbox/vercel.ts, fronted by a thin re-export in apps/web/lib/sandbox/index.ts that exposes a driver-agnostic SandboxProvider interface. Any backend that can provision a Linux VM, run commands, and read/write files can drop in — E2B, Modal, a remote Firecracker service, a local Docker daemon for development, etc.
apps/web/lib/sandbox/index.ts picks the default provider via getVercelSandboxProvider() in vercel.ts. The exported sandboxProvider is typed as the backend-agnostic SandboxProvider interface defined in apps/web/lib/sandbox/types.ts, so any implementation of that interface is a drop-in replacement. To switch:
-
Install whatever SDK your backend needs.
-
Add a sibling factory — e.g.
apps/web/lib/sandbox/e2b.ts:import "server-only" import type { SandboxProvider } from "./types" class E2BSandboxProvider implements SandboxProvider { async create(opts) { /* call your SDK, return a SandboxInstance */ } async get(opts) { /* call your SDK, return a SandboxInstance */ } } export function getE2BSandboxProvider(): SandboxProvider { return new E2BSandboxProvider() }
-
Change the single import in
index.tsto point at your new factory.
The SandboxInstance interface the provider must return is small (runCommand, writeFiles, readFileToBuffer, domain, extendTimeout, name, status) — see apps/web/lib/sandbox/types.ts for the exact shape. Everything else in the app — lib/sandbox-actions.ts, the agent's tool executor, the logs SSE route — is written against this interface and needs no changes when the backend swaps.
Project thumbnails are screenshotted by a headless browser, resized, and uploaded to a public-readable blob store. The default backend is @vercel/blob via apps/web/lib/blob/vercel.ts, fronted by a thin re-export in apps/web/lib/blob/index.ts that exposes a backend-agnostic BlobStore interface. Any object store with a public-URL read path works — S3, R2, GCS, Supabase Storage, a self-hosted MinIO bucket, etc.
apps/web/lib/blob/index.ts picks the default store via getVercelBlobStore() in vercel.ts. The exported blobStore is typed as the backend-agnostic BlobStore interface defined in apps/web/lib/blob/types.ts, so any implementation of that interface is a drop-in replacement. To switch:
-
Install whatever SDK your backend needs.
-
Add a sibling factory — e.g.
apps/web/lib/blob/s3.ts:import "server-only" import type { BlobStore } from "./types" class S3BlobStore implements BlobStore { async put(key, body, opts) { /* call your SDK, return { url } */ } } export function getS3BlobStore(): BlobStore { return new S3BlobStore() }
-
Change the single import in
index.tsto point at your new factory.
The BlobStore interface is intentionally tiny (put(key, body, opts) → { url }) — see apps/web/lib/blob/types.ts for the exact shape. Callers (lib/thumbnail/capture.ts) only ever see the abstract interface and need no changes when the backend swaps.
Set these in Vercel (Project Settings → Environment Variables) and in a local .env.local for development:
# --- Better Auth ---
# URL for the current deployment. Required in production. Optional locally —
# we default to http://localhost:$PORT (3000 if PORT unset). On Vercel preview
# deploys, leave unset and we fall back to https://$VERCEL_URL.
# BETTER_AUTH_URL=http://localhost:3000
# Stable URL registered with the GitHub OAuth app. Same value in production
# and on every preview deploy — the oAuthProxy plugin needs it to route
# preview sign-ins through the production callback.
BETTER_AUTH_PRODUCTION_URL=https://build.screenplay.space
# 32 random bytes, hex-encoded. `openssl rand -hex 32`. MUST be identical
# across production and all preview deploys (see "Secret sharing" above).
BETTER_AUTH_SECRET=...
# --- GitHub OAuth App ---
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
# --- Postgres ---
# Used by Better Auth, Drizzle, and lib/kv. Any Postgres works — the default
# factory (lib/db/neon.ts) uses Neon's serverless HTTP driver; swap it for
# postgres-js / node-postgres if you're pointing at something else.
DATABASE_URL=postgres://...
# --- Yjs host ---
# Credentials for whatever Yjs host is configured. The default implementation
# targets Liveblocks and only needs a server-side secret key from
# https://liveblocks.io/dashboard — it's consumed by lib/yjs-host/liveblocks-server.ts
# and never leaves the server. To point at a different host, add sibling
# `*-server.ts` / `*-client.tsx` files under lib/yjs-host/, flip the re-exports
# in lib/yjs-host/index.ts + lib/yjs-host/client.tsx, and set whatever env
# vars that host needs instead of LIVEBLOCKS_SECRET_KEY.
LIVEBLOCKS_SECRET_KEY=sk_...
# --- Anthropic ---
# Read automatically by the Anthropic SDK
ANTHROPIC_API_KEY=sk-ant-...
# --- Env-var encryption ---
# 32 random bytes, hex-encoded (64 hex chars). Used to encrypt per-workspace
# env vars before storing them in Postgres (see lib/env-store.ts).
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=<64 hex chars>
# --- Thumbnail capture ---
# HMAC secret for the short-lived tokens that gate /[roomId]/render. The
# capture pipeline hits that route with a signed token because it can't carry
# a user session. Any long random string works.
# Generate with: openssl rand -hex 32
THUMBNAIL_RENDER_SECRET=<64 hex chars>
# --- Blob store ---
# Credentials for whatever blob store is configured. The default
# implementation (lib/blob/vercel.ts) wraps Vercel Blob and only needs a
# read/write token. On Vercel, connect a Blob store to your project and
# BLOB_READ_WRITE_TOKEN is injected automatically; locally, run
# `vercel env pull .env.local` after connecting the store. To point at a
# different backend, add a sibling factory under lib/blob/, flip the import
# in lib/blob/index.ts, and set whatever env vars that backend needs.
BLOB_READ_WRITE_TOKEN=...The default sandbox provider is @vercel/sandbox, which authenticates via OIDC. In production on Vercel the OIDC token is injected automatically — no extra variables required. For local development, link the project once and pull a short-lived OIDC token into your env file:
vercel link
vercel env pull .env.localThis populates VERCEL_OIDC_TOKEN (valid for ~12 hours — re-run vercel env pull when it expires).
If you've swapped in a different provider under apps/web/lib/sandbox/, set whatever env vars that backend needs instead (e.g. E2B_API_KEY) and consume them inside the provider's factory function.
- Import the repo into a new Vercel project.
- Add the environment variables listed above. Scope each one correctly:
BETTER_AUTH_URL: Production only, set to your custom domain (e.g.https://build.screenplay.space). Leave it unset on Preview so each preview deploy auto-useshttps://$VERCEL_URL.BETTER_AUTH_PRODUCTION_URL,BETTER_AUTH_SECRET,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET: Production + Preview (Vercel "all environments" scope). These must stay identical across every deploy — the oAuthProxy plugin signs state on production and verifies it on the preview that started the sign-in.- Everything else (
DATABASE_URL,LIVEBLOCKS_SECRET_KEY(or whatever your Yjs host needs),ANTHROPIC_API_KEY,ENCRYPTION_KEY,THUMBNAIL_RENDER_SECRET,BLOB_READ_WRITE_TOKEN(or whatever your blob store needs)): Production + Preview.
- Deploy. The first build runs the checked-in Drizzle migrations against your database, then runs
next build.
pnpm install
cp apps/web/.env.local.example apps/web/.env.local # then fill in values
cd apps/web && pnpm db:migrate # apply migrations to your database
pnpm devThe app runs on http://localhost:3000.
pnpm dev # start the Next.js dev server with Turbopack
pnpm build # production build (runs drizzle-kit migrate, then next build)
pnpm lint # ESLint
pnpm typecheck # tsc --noEmit
pnpm format # Prettier
# Database (run from apps/web)
pnpm db:generate # generate a new SQL migration from schema changes — commit the output
pnpm db:migrate # apply committed migrations to $DATABASE_URL
pnpm db:push # push the schema directly without a migration file (throwaway dev only)
pnpm db:studio # open Drizzle StudioThis project is licensed under the MIT License — see the LICENSE file for details.