sessclone

Configuration

Every variable the deployment and the Collector read, and what each one is for.

The three things a self-hoster supplies are reached by plain configuration, with no provider in the code path: the database is a Postgres URL, storage is any S3-compatible endpoint, and the public base URL is whatever the deployment answers on. Auth is the exception, and is named as one — it is Supabase Auth.

.env.example in the repository is the same list in copyable form. Real values live in .env, which is gitignored, or in your deployment's own secret store.

The server's .env belongs in apps/web

Next.js reads environment files from its own project root, so a .env at the repository root is read by nothing when you run the app directly, and the first page that needs one fails with NEXT_PUBLIC_SUPABASE_URL is not set. Docker Compose is the exception: it reads the root .env.

Server

Database

VariableRequiredWhat it is
DATABASE_URLyesEverything the dashboard reads. Any Postgres; Supabase is not a requirement
INGEST_DATABASE_URLyesWhat the ingest route writes as. The owning role, not sessclone_app

Two variables, because they are two roles. Point DATABASE_URL at sessclone_app, a role that owns nothing. Postgres applies no row-level security to a superuser or to the role that owns the tables, so a dashboard connected as the role that applied the migrations has every policy switched off and no error to say so. Ingest writes tables sessclone_app has no insert grant on, so it gets the owning role through its own variable, and the ingest route is the only code that reads it.

Neither has a default, and neither falls back to the other. A missing one throws DATABASE_URL is not set (or INGEST_DATABASE_URL is not set) on the first request that needs it. A silent fallback would make a misconfigured deployment — both pointed at the owning role, row-level security off — appear to work.

Supabase (sign-in)

VariableRequiredWhat it is
NEXT_PUBLIC_SUPABASE_URLyesProject URL. NEXT_PUBLIC_ because the browser needs it
NEXT_PUBLIC_SUPABASE_ANON_KEYyesAnon key. Public by design; row-level security protects the rows, not this key
SUPABASE_SERVICE_ROLE_KEYnoServer only. Lets the retention sweep remove a deleted account's sign-in

SUPABASE_SERVICE_ROLE_KEY has one use: once an account's 14-day deletion grace ends, the daily retention sweep removes its sign-in through Supabase's Admin API. Nothing else reads it, and nothing the browser reaches does. Without it, deletions still scrub every row on time, but the sign-in stays and the Admin panel lists the account until the key is set. Keep it server-side.

Sign-in is GitHub OAuth and a magic link; no password is created or stored. Both are configured in the Supabase project: GitHub needs a client id and secret under Authentication, and both need <NEXT_PUBLIC_APP_URL>/auth/callback in the project's allowed redirect URLs.

The magic-link email is sent by Supabase and configured in the Supabase project's own SMTP settings. The invitation email and the sign-up note are the app's own, below.

Templates for the Supabase emails, in the app's look, are in the repo's supabase/templates/; paste them into Authentication > Emails.

Email (SMTP)

VariableRequiredWhat it is
SMTP_URLnoConnection URL, e.g. smtp://user:pass@smtp.example.com:587 or smtps://…:465
SMTP_FROMnoFrom address on the invitation, e.g. SessClone <no-reply@example.com>

Both or neither. With them set, inviting someone emails them the join link; without, the inviter is given the link to pass on themselves — the same link the email would carry. smtp:// uses STARTTLS when the server offers it; smtps:// is TLS from the first byte.

The same two variables send the platform admins a note each time somebody signs up and creates an Org waiting for approval, with a link to that Org under /admin → Orgs. A New Org started from the Org switcher sends the same note, the same way — never with approval off or once the Org is active. Unset, nothing is sent; the Admin panel lists waiting Orgs first and counts them on its navigation link either way.

Sign-up approval

VariableRequiredDefaultWhat it is
SIGNUP_APPROVALnoonoff lets a new Org in without a platform admin approving it

On by default, self-hosted deployments included. An Org whose subscription is inactive — where every sign-up starts, on the plan it picked — or which has no subscription row, or is cancelled, is locked: the dashboard shows only a waiting page, the Org switcher and Sign out, no key can be created, and ingest answers its existing keys with the same 401 as any unknown key. past_due is not locked; it keeps its notice and works. A platform admin approves an Org by setting it active under /admin → Orgs, where waiting Orgs are listed first. A New Org started from the Org switcher waits in the same way, and a person may have only one Org of their own waiting at a time.

Any value other than off (case-insensitive) leaves it on. With it off, every status behaves as it did before the lock: a notice on every page, collection working. Read per request, so a restart is enough to change it.

Public base URL

VariableRequiredWhat it is
NEXT_PUBLIC_APP_URLyesOrigin this deployment answers on, e.g. https://sessclone.example.com. No trailing slash

Used to build the sign-in redirect, invite links and the install instructions a Member is shown, so your team is told to report to your deployment. It is not derived from request headers: a forwarded Host is attacker-controllable, and an invite link is a credential.

Public pages (optional)

VariableRequiredWhat it is
SEARCH_INDEXINGnoon lets search engines crawl the public pages. Anything else blocks every crawler
NEXT_PUBLIC_CONTACT_EMAILnoAddress the public pages offer for contact. Unset, contact links are hidden
NEXT_PUBLIC_CLOUDFLARE_ANALYTICS_TOKENnoCloudflare Web Analytics site token. Cookieless; marketing pages and docs
NEXT_PUBLIC_CLARITY_PROJECT_IDnoMicrosoft Clarity project id. Marketing pages only, never the dashboard

A self-hosted copy stays out of search results and loads no analytics unless you set these. Canonical links, the sitemap and llms.txt are built from NEXT_PUBLIC_APP_URL. Clarity asks visitors in Europe first and does not load if they decline. The NEXT_PUBLIC_ variables are read at build time, so a change needs a redeploy.

The Privacy and Terms pages describe the hosted service at sessclone.com: its providers and its analytics. Replace them with your own before you open a deployment to people outside your team.

Storage

Transcripts go straight to storage through a presigned PUT; the application never carries the bytes. Only the S3 API is used, so Supabase Storage, Cloudflare R2, AWS S3, Oracle Cloud Object Storage and MinIO are the same code path. With these unset, archival is off and everything else works.

VariableRequiredDefaultWhat it is
STORAGE_ENDPOINTfor archival—S3 API endpoint URL. The provider's S3 endpoint, not its dashboard
STORAGE_REGIONnoautoRegion. auto suits R2 and Supabase; AWS needs the real one, e.g. eu-west-2
STORAGE_BUCKETfor archival—Bucket holding transcripts. Must not be public: reads are presigned
STORAGE_ACCESS_KEY_IDfor archival—Access key id
STORAGE_SECRET_ACCESS_KEYfor archival—Secret access key
STORAGE_FORCE_PATH_STYLEnotruePath-style addressing. Required by MinIO and Supabase; AWS accepts it
STORAGE_PRESIGN_TTL_SECONDSno300Life of an issued URL. A presigned URL is a bearer credential — keep it short

Self-hosting has the script that checks a bucket speaks every part of the S3 API this uses, and the CORS rule the transcript viewer needs. That script reads the same variables, plus STORAGE_COMPAT_CREATE_BUCKET=true to create the bucket before checking it.

Public site (optional)

VariableRequiredDefaultWhat it is
ENABLE_LANDINGnoofftrue serves the landing page at / and /pricing
ENABLE_DOCSnoofftrue serves these docs at /docs and their search at /api/search
ENABLE_DEMOnoofftrue runs the read-only demo at /demo (below) and shows "Try the demo" beside sign-up

Each is true or false, and unset is off, so a self-hosted copy is the dashboard alone unless you opt in. Only true (any case) turns one on.

  • Landing off: / sends a signed-out visitor to sign-in and a signed-in one to the dashboard. /pricing returns 404; /privacy and /terms stay, because sign-up and the dashboard's Legal notices link to them. Sign-up keeps its plan step. The dashboard's logo opens Costs.
  • Landing on: a signed-in person who types / or follows a link to it from elsewhere gets the dashboard. The dashboard's logo still opens the landing page, whose header then offers Dashboard in place of Sign in.
  • Docs off: /docs and /api/search return 404, and every docs link on the site points at the same page on https://sessclone.com/docs.
  • The sitemap and llms.txt list only what the deployment serves.

The flags are read in two places. The Proxy reads them on every request, for the 404s and the redirect from /; the public pages read them when they are prerendered at build time, for their links and buttons. Changing a flag needs a rebuild and redeploy, not only a restart: on Vercel, redeploy after changing the variable; with Docker, rebuild the image (compose.yaml passes the three as build arguments).

Live demo (optional)

VariableRequiredDefaultWhat it is
ENABLE_DEMOnoofftrue runs a read-only demo at /demo (see Public site above)
CRON_SECRETwith ENABLE_DEMO—Bearer secret for /api/demo/refresh. Unset means the route refuses every call

Off by default, so a self-hosted copy has no demo unless you opt in. With ENABLE_DEMO=true, /demo sets a cookie that shows a visitor with no session two made-up Orgs, as Owner of one and Member of the other; every save answers "This is a demo", because each of the visitor's database transactions is read-only. A signed-in person always sees their own Orgs.

The data is generated, never copied from real usage: six invented people per Org over the last 60 days. GET or POST /api/demo/refresh with Authorization: Bearer <CRON_SECRET> creates the demo Orgs when missing, deletes what is older than 60 days, and fills any missing day, so the first call backfills the whole window. It is idempotent. On Vercel, apps/web/vercel.json calls it daily at 23:00 UTC; elsewhere, schedule it yourself once a day, after 23:00 UTC. It writes as INGEST_DATABASE_URL, and stores a few hundred kilobytes of made-up transcripts when storage is configured.

Toggling ENABLE_DEMO needs a redeploy. The landing and pricing pages are prerendered at build time, so their "Try the demo" button appears or disappears only with the next build. /demo, the banner and the refresh read the variable per request and follow it at once, so turning it off without a redeploy leaves a button that leads to a 404.

Retention sweep

VariableRequiredWhat it is
RETENTION_SWEEP_SECRETnoShared secret for POST /api/retention/sweep. Unset means the route refuses every call

Retention is a window per Org, in days, set by an Owner or an Admin under Org settings and capped by the Org's Tier. Nothing enforces it on a schedule, because your scheduler is yours: POST /api/retention/sweep with Authorization: Bearer <secret> removes transcripts past their window — the rows and the stored objects together, oldest first, at most 500 per call — and answers { removed, remaining } so a backlog can be drained by calling again. Turns are never touched: the spend history survives every transcript it describes.

The same call does two more daily jobs: it removes the transcripts of an Org that moved to a Tier without transcripts once its 7 days are up, and it finishes account deletions whose 14-day grace has ended. Its answer adds accounts with how many were scrubbed and how many sign-ins were removed.

Set the windows before the first sweep

Upgrading an existing deployment sets every Org to 90 days, which no Org chose. The first sweep on a deployment that has been collecting for longer destroys every transcript older than that. Set the windows first — update orgs set retention_days = 365; or per Org — then configure the secret.

The route answers 401 for a wrong secret and 503 when the secret is unset, when storage is not configured, or when the sweep failed (which rolls the rows back).

Published pricing

VariableRequiredWhat it is
PRICING_URLnoMarkdown pricing page, e.g. https://platform.claude.com/docs/en/about-claude/pricing.md
PRICING_REVALIDATE_SECRETnoShared secret for POST /api/pricing/revalidate. Unset means the route refuses every call

PRICING_URL is read by Fetch latest pricing on /admin/rates. Nothing fetched is written until a platform admin approves it.

The public pricing pages cache the Tier table. Saving a Tier in the admin panel clears that cache itself; POST /api/pricing/revalidate with Authorization: Bearer <secret> is for a Tier changed outside the panel. The cache is per instance, so several instances or a serverless deployment each refresh on their own schedule unless you add a shared cache handler.

Docker Compose

Read by compose itself, from the .env at the repository root.

VariableRequiredDefaultWhat it is
WEB_PORTno3000Host port the dashboard is published on. Deliberately not PORT
POSTGRES_PASSWORDwith db only—Password for the sessclone role in the bundled Postgres (--profile db)
POSTGRES_PORTno5432Host port the bundled Postgres is published on, bound to loopback

WEB_PORT is not PORT because next start reads PORT as the port it listens on inside the container, and the same file is handed to both.

Collector

The Collector reads its configuration from the environment, or from the answers given when the plugin was enabled, so your team can point at your deployment without editing the plugin. An exported variable wins over the answer.

VariableRequiredDefaultWhat it is
SESSCLONE_URLnohttps://sessclone.comBase URL of the deployment. Matches the server's NEXT_PUBLIC_APP_URL
SESSCLONE_STATE_DIRnoper platform, belowWhere the cursors and the retry queue are kept
SESSCLONE_DEVICEnoderivedPins this environment's Device key instead of deriving one
SESSCLONE_DEBUGno—Set to anything to print a hook's swallowed failure to stderr. Off, a hook is silent

There is no variable for the API key: it comes only from the plugin's own setup prompt (url and api_key, both optional, in packages/plugin/.claude-plugin/plugin.json), stored in Claude Code's secure credential store — the Claude plugin directory refuses a plugin that reads a credential from the user's environment and sends it on. SESSCLONE_URL wins over the prompt's url answer when both are set. With no key at all, reports carry no Authorization header, which is what lets a Claude Code cloud environment's own proxy credential stand in for it.

An unset SESSCLONE_URL (and no url answer) now reports to the hosted service at https://sessclone.com, not to a laptop that never answers — so a self-hoster must set one or the other, or a machine quietly reports its usage to somebody else's deployment. A value that is not an http or https URL is refused at session start.

Connected, and to which Org. hooks/session-start.mjs asks GET /api/ingest, which answers { org: <name> } for a live key (or a live proxy-added credential) and the same 401 a report would get otherwise, and marks a live key as used. The hook records the answer in the state directory (connection.json: the state, the Org, the URL, the key's first three characters and length, never the key) and prints one line: "sessclone connected: reporting to <Org>." the first session after a key or deployment changes, and "sessclone is not connected: …" every session for as long as the deployment refuses it or none is set. While it is refused the Collector sends nothing, rather than resending a session's history on every turn. Claude Code gives the key to hooks and to the plugin's mod only: /sessclone-status prints that record with the Device, this session's unsent Turns, the reports waiting and the last push (/sessclone:status, where mods cannot load, all but the unsent Turns). /sessclone-sync runs the session-start check and sweep at once, with the key the mod hands it; /sessclone:sync leaves a request that the Stop hook ending the same turn carries out.

The Device key is normally host:<hostname> on a machine and cloud:<account uuid> in Claude Code on the web. Set SESSCLONE_DEVICE only when one account runs several environments that should be counted separately — a CI fleet beside a laptop, say. The value is used verbatim, so it also pins an identity across a rename.

The state directory deliberately never lands under ~/.claude, because Claude Code's own cleanupPeriodDays sweep deletes under ~/.claude/projects/ after 30 days by default and would take the cursors and the queue with it:

PlatformDefault state directory
Linux$XDG_STATE_HOME/sessclone, else ~/.local/state/sessclone
macOS~/Library/Application Support/sessclone
Windows%LOCALAPPDATA%\sessclone, else %USERPROFILE%\AppData\Local\sessclone

Only the Linux row has been observed on a real machine so far; treat the other two as the intended behaviour.

HTTPS_PROXY (or https_proxy) is honoured: when one is set, the Collector restarts its hook once with NODE_USE_ENV_PROXY=1 so Node's fetch goes through the proxy. That is what a Claude Code cloud environment needs, since its egress proxy is what adds the credential. You never set NODE_USE_ENV_PROXY yourself.

CLAUDE_CONFIG_DIR is Claude Code's variable. The Collector reads it — CLAUDE_CONFIG_DIR, else ~/.claude, exactly as Claude Code does — to find transcripts, and never writes inside it.

Not configured here

  • Rates. Prices ship as reviewed migrations and are edited in the platform admin area, never read from the environment. A price in an env var is a price nobody reviewed.
  • Tiers and retention windows. Rows in the database, set per Org.
  • The Org timezone. An Org setting, because two Orgs on one deployment bucket their days differently.

On this page