workbench Docs

Configuration

Environment variables

Every variable the server reads, with its validation, default, and effect.

Configuration is one Zod schema parsed at module import time. A validation failure crashes the process on boot, before routes are registered or plugins load — so a bad value fails loudly and immediately rather than at first use.

Two variables are genuinely required. Everything else has a default.

VariableRequirementGenerate with
ENCRYPTION_KEYexactly 64 hex charactersopenssl rand -hex 32
SESSION_SECRETat least 32 charactersopenssl rand -base64 32

Both default to "" outside tests, and "" fails validation — which is how the requirement is enforced. When NODE_ENV=test they fall back to fixed development values so the suite runs without setup.

`ENCRYPTION_KEY` is unrecoverable and unrotatable

It is the AES-256-GCM key for every stored OAuth token, cookie bundle, and API key, read once at module load. There is no re-encryption path. If you change it or lose it, every stored credential becomes undecryptable and every user must reconnect every integration. It must also stay identical across a SQLite-to-PostgreSQL migration, which copies ciphertext verbatim. Back it up with the same care as the database.

Core#

VariableType / validationDefaultRequiredPurpose
PORTstring3000noListen port. The server binds 0.0.0.0
NODE_ENVdevelopment | production | testdevelopmentnoSelects test defaults for the two secrets; also makes the jot cookie Secure in production
SERVER_PUBLIC_URLURLhttp://localhost:3000noThe server's own public origin. Drives every OAuth redirect URI, the OAuth metadata documents, the access token's iss/aud, and half the live-view origin allowlist
PORTAL_URLURLhttp://localhost:5173noWhere SSO and connect flows redirect the user; the other half of the live-view origin allowlist
PORTAL_DIST_DIRstring./portalnoFirst candidate path for the built portal SPA
PLUGINS_DIRstring./pluginsnoExternal plugin directory. Always resolved to an absolute path before import
The default `PORTAL_URL` does not match the dev portal

The Vite dev server binds port 3000 with strictPort, so the default http://localhost:5173 is wrong for local development. Unless you set PORTAL_URL=http://localhost:3000, the CDP live-view origin allowlist rejects the dev portal with a 403 and browser-session capture fails.

Database#

VariableType / validationDefaultRequiredPurpose
DATABASE_URLstring./data/tokens.dbnoBackend selector and connection string. A postgres:// or postgresql:// prefix selects PostgreSQL; anything else is a SQLite file path
PG_POOL_MAXpositive integer2noMaximum pooled connections per worker
PG_CONNECT_TIMEOUT_MSnon-negative integer5000noMilliseconds to wait for a free pool slot. 0 = unlimited

DATABASE_URL does more than pick a database: the browser-profile directory and the jots directory both default to siblings of its dirname.

Authentication and SSO#

VariableType / validationDefaultRequiredPurpose
ENCRYPTION_KEYstring, exactly 64 chars (hex)""; all-zeros when NODE_ENV=testyesAES-256-GCM key for tokens, cookie bundles, and the API-key copy
SESSION_SECRETstring, min 32 chars""; fixed value when NODE_ENV=testyesKeys five credentials: the four HS256 JWTs (portal session, MCP OAuth access, connect, curl-session) and the jot unlock cookie, which is a plain HMAC-SHA256 digest rather than a JWT. Rotating it invalidates all five
GOOGLE_CLIENT_IDstringnoGoogle Workspace SSO for portal login. Its presence alone enables the google provider
GOOGLE_CLIENT_SECRETstringnoRequired for the token exchange; without it the auth URL builds but the exchange throws
KEYCLOAK_ISSUER_URLURLnoOIDC discovery base for Keycloak SSO
KEYCLOAK_CLIENT_IDstringnoKeycloak client, also the ID-token audience
KEYCLOAK_CLIENT_SECRETstringnoKeycloak is a confidential client — no PKCE on this flow

Keycloak counts as configured only when all three of its variables are set. The Google callback URL is ${SERVER_PUBLIC_URL}/api/auth/google/callback and the Keycloak one is ${SERVER_PUBLIC_URL}/api/auth/keycloak/callback — both server-side, not portal-side.

OAuth server (MCP clients)#

VariableType / validationDefaultRequiredPurpose
OAUTH_ACCESS_TOKEN_TTL_SECONDSpositive integer3600noLifetime of an MCP OAuth access token, and the expires_in value returned by /token. Also the revocation lag — revoking an agent does not invalidate live access tokens
CONNECT_TTL_SECONDSpositive integer600noPending-connection TTL and connect-JWT lifetime, used by connect, the browser live-URL route, and browser_live_url

Browser sessions#

VariableType / validationDefaultRequiredPurpose
BROWSER_PROFILES_DIRstringdirname(DATABASE_URL)/browser-profilesnoRoot for per-user Chromium profiles
BROWSER_SESSION_TTL_SECONDSpositive integer300noIdle cutoff before a warm browser session is killed. Checked every 30 seconds
BROWSER_PROFILE_TTL_DAYSnon-negative integer30noAge at which an unused whole profile is deleted. 0 disables deletion
BROWSER_PROFILE_REAP_INTERVAL_SECONDSpositive integer3600noDisk-reaper interval. It also runs once immediately at boot
BROWSER_DISK_CACHE_MBnon-negative integer32noBecomes Chromium's --disk-cache-size
`BROWSER_PROFILE_TTL_DAYS` deletes credentials

Deleting a profile logs that user out of every cookie-auth integration at once. The 30-day default is deliberately conservative. The cache trim that runs far more often is free by comparison. Raise it or set 0 if your users connect cookie integrations rarely.

Capture proxy — read straight from process.env#

These three are not in the config schema, so they are invisible to the boot-time validation and easy to miss when reading config.ts. They are read directly where Chromium is spawned.

VariableTypeDefaultRequiredPurpose
CAPTURE_PROXYstringnoPassed to Chromium as --proxy-server=
CAPTURE_PROXY_USERNAMEstringnoProxy username
CAPTURE_PROXY_PASSWORDstringnoProxy password

Proxy authentication is armed only when all three are set. The answering handler responds to proxy auth challenges specifically, and declines anything else. Because they bypass the schema, a typo in one of these names fails silently rather than at boot.

Jots#

VariableType / validationDefaultRequiredPurpose
JOTS_DIRstringdirname(DATABASE_URL)/jotsnoRoot for deployed jot files
JOTS_MAX_BYTESpositive integer5242880 (5 MiB)noPer-file and total decompressed size cap
JOTS_MAX_FILESpositive integer1000noMaximum files in one archive, enforced during extraction
JOTS_UPLOAD_TTL_SECONDSpositive integer300noLifetime of a single-use upload token

Cluster#

VariableType / validationDefaultRequiredPurpose
CLUSTER_ENABLED"true" | "false" | "1" | "0"falsenoForks one worker per available core

Note the enum: an arbitrary truthy-looking string such as yes is a validation error that crashes the boot, not a falsy value.

Cluster mode requires PostgreSQL and multiplies connections

With a SQLite DATABASE_URL the process exits with status 1 and a clear message — SQLite cannot be shared across processes. The total connection count becomes PG_POOL_MAX × worker count, which must stay well under the server's max_connections. In-flight connection records are held in process-local maps, so they are not shared across workers. (The SSO nonce is stored in the pending_auth row and is shared across workers through the database.)

Audit and telemetry#

VariableType / validationDefaultRequiredPurpose
AUDIT_LOG_DESTsqlite | stdout | kafkasqlitenoWhere audit events go
AUDIT_LOG_KAFKA_BROKERSstringnoDeclared but never read
AUDIT_LOG_KAFKA_TOPICstringaudit-lognoDeclared but never read

sqlite writes to whatever backend DATABASE_URL selects, PostgreSQL included, despite the name.

The `kafka` destination is not implemented

AUDIT_LOG_KAFKA_BROKERS and AUDIT_LOG_KAFKA_TOPIC pass validation and are then read by nothing. Selecting AUDIT_LOG_DEST=kafka logs "Kafka not implemented, falling back to stdout" and prints the event as a JSON line.

There is no variable for tracing. OpenTelemetry instrumentation is registered but starts with no exporter configured, so spans are produced and nothing exports them by default. Any export has to come from the standard OTEL_* variables the OTel SDK reads on its own.

Plugin credentials#

Per-plugin OAuth clients are read directly from process.env by name, not declared in the schema. The prefix is the plugin name converted from kebab-case to UPPER_SNAKE_CASE.

PatternExamplePurpose
<PLUGIN>_CLIENT_IDGOOGLE_GMAIL_CLIENT_IDOAuth client ID. Without it the integration reports configured: false and cannot be connected
<PLUGIN>_CLIENT_SECRETGOOGLE_GMAIL_CLIENT_SECRETOAuth client secret. Empty or unset means a public, PKCE-only client — which is valid, not an error
<PLUGIN>_ALLOWED_INSTANCESGITLAB_ALLOWED_INSTANCESComma-separated extra self-hosted origins for a plugin that declares instance

There are 16 built-in plugins. The 14 that use OAuth, by the prefix they take: ASANA, ATLASSIAN_BITBUCKET, ATLASSIAN_CONFLUENCE, ATLASSIAN_JIRA, GITHUB, GITLAB, GOOGLE_CALENDAR, GOOGLE_DOCS, GOOGLE_DRIVE, GOOGLE_GEMINI, GOOGLE_GMAIL, GOOGLE_SHEETS, GOOGLE_SLIDES, SLACK.

The other two take no _CLIENT_ID because they do not use OAuth: newrelic (NEWRELIC) is API-key auth, and httpbin-cookie (HTTPBIN_COOKIE) is cookie auth.

<PLUGIN>_ALLOWED_INSTANCES entries are each normalised: https only, no userinfo in the URL, and no private or loopback address literals. The manifest's cloud default is always allowed — with the variable unset, it is the only allowed origin. This is what stops a shared client secret being POSTed to an attacker-chosen host.

New Relic's key, region, and account ID are entered per user in the portal — the running server never reads them from the environment. The one exception is the shipped verification script, below.

Migration and tooling#

Read only by the shipped scripts and by tests — never by the running server.

VariableRead byPurpose
SOURCE_SQLITE_PATHmigrationOverrides the source database path
TARGET_DATABASE_URLmigrationTarget connection string. Must start postgres:// or postgresql://
PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASEmigrationlibpq-style fallback when TARGET_DATABASE_URL is unset. Host, user, and database are required; port defaults to 5432
TEST_POSTGRES_URLtest suiteEnables the PostgreSQL half of the database-adapter suite. Without it that half skips loudly
NEW_RELIC_API_KEYscripts/verify-newrelic.tsNerdGraph user key the script authenticates with
NEW_RELIC_REGIONscripts/verify-newrelic.tsUppercased; selects the region-scoped NerdGraph endpoint. Defaults to US
NEW_RELIC_ACCOUNT_IDscripts/verify-newrelic.tsAccount to query, parsed as a number
`.env.example` is incomplete

It documents 11 of the 31 schema variables. Missing entirely: NODE_ENV, PORTAL_DIST_DIR, OAUTH_ACCESS_TOKEN_TTL_SECONDS, every BROWSER_*, every JOTS_*, both PG_*, both AUDIT_LOG_KAFKA_*, all three KEYCLOAK_*, CLUSTER_ENABLED (present only as a comment), every CAPTURE_PROXY*, and <PLUGIN>_ALLOWED_INSTANCES. On the plugin side only the seven Google plugins get real _CLIENT_ID / _CLIENT_SECRET placeholders: six more (GITHUB, SLACK, ATLASSIAN_JIRA, ATLASSIAN_CONFLUENCE, ATLASSIAN_BITBUCKET, ASANA) appear only as commented examples, and GITLAB is absent altogether. Treat this page as the list, not that file.