temperkb.io.Doing a full ground-up enterprise install? This playbook is one phase.
For the single end-to-end sequence (deploy → SAML → org → agents) see
enterprise install.
Vercel (one project)
┌────────────────────────────────────────┐
CLI / MCP client │ api/mcp.rs MCP server │
──────────────────▶│ api/axum.rs REST API │
│ api/internal.rs drains + crons │
Vercel cron (×12) │ api/oauth/*.ts OAuth + SAML │
──────────────────▶│ │
└────────────────────────────────────────┘
│ │
▼ ▼
Neon PG 17 Auth0 tenant
(pgvector) (JWT issuer)vercel.json at the repo root; handle: filesystem runs first, then the| Route | Function | What it is |
|---|---|---|
/mcp, /mcp/(.*) | api/mcp.rs | The MCP server. |
/api/embed/dispatch, /api/embed/warm, /api/region/dispatch, /api/slack/intents/reap, /api/as/reap, /api/internal-calls/health, /api/erasure/drain, /api/sensitivity/sweep, /api/shape/reconcile | api/internal.rs | Cron-driven work. Not called by clients — see below. |
/internal/(.*) | api/internal.rs | Server-to-server, HMAC-gated (the SAML reconcile channel). |
/oauth/token, /oauth/jwks, /oauth/authorize, /oauth/clients, /oauth/saml/{login,acs,metadata}, /.well-known/oauth-authorization-server | api/oauth/*.ts | The authorization server. TypeScript, not Rust. |
/oauth/(.*), /.well-known/(.*) | api/mcp.rs | Whatever the named OAuth routes above did not claim. |
/(.*) | api/axum.rs | The REST API (catch-all). |
framework is null; there is no framework-level routing. SQLX_OFFLINE=true.sqlx/ cache rather than a live database.api/internal.rs runs the background work, and nothing invokes it unless thevercel.json declares twelve:| Schedule | Path | What stalls without it |
|---|---|---|
| every minute | /api/embed/dispatch?shard=0..3 (four entries) | Embedding: new and changed resources are never vectorised, so semantic search does not see them. |
| every 2 minutes | /api/embed/warm | Cold-start latency on the embedding path. |
| hourly | /api/slack/intents/reap | Expired Slack link intents are never swept. |
| every minute | /api/region/dispatch | Region materialization: cognitive-map regions never form. |
| daily at 03:17 | /api/as/reap | Retention: the authorization server's tables and abandoned staged blob uploads are never reaped. |
| every 15 minutes | /api/internal-calls/health | Reconcile-channel health: a fail-open internal call that never reached the instance produces no operator-visible signal. |
| every minute | /api/erasure/drain | Erasure: deletion work is never processed off the request path. |
| every 5 minutes | /api/sensitivity/sweep | Sensitivity sweep: personal data that lands in the corpus by accident is never found, and the digests of erased places are never expired. Scans nothing until you opt in with SENSITIVITY_SWEEP_ENABLED and enable detectors. |
| every 5 minutes | /api/shape/reconcile | Shape reconciliation: an artifact committed before its family's shape was declared, or rehomed under another shape, never gains a verdict and reads declared_not_yet_checked. |
VERCEL_URL). Your public domain answers them 404, so a cron pointed at theVERCEL_URL the pin falls away and the bearerapi/internal.rs is givenmaxDuration: 300 for this reason — the drains are long-running relative to ainternal/development/drain-operator-queries.md in the repository.neondb database and run:vector provides the pgvector embedding type used by the search pipeline.pg_uuidv7 provides in-database UUIDv7 generation. plpgsql is enabled byCREATE EXTENSION.DATABASE_URL) — the host contains -pooler. Used atDATABASE_URL_UNPOOLED) — no -pooler suffix. Used forpostgresql://<user>:<password>@<host>/neondb?sslmode=require&channel_binding=requireDATABASE_URL=<DATABASE_URL_UNPOOLED> sqlx migrate runmigrations/. sqlx is the single migration authority —DATABASE_URL and DATABASE_URL_UNPOOLED per previewUsing Okta instead? This section is Auth0-specific. For standing up the
same instance against an Okta tenant in an enterprise context, see
self-host with Okta — it covers the custom
authorization server, API Access Management requirement, and the
Okta-specific environment and CLI configuration. The rest of this playbook
(Neon, Vercel, verify) applies unchanged.
AUTH_AUDIENCE in your Vercel environment; a conventional value ishttps://<instance>/api. See auth identity forMCP_AUDIENCE restatement.MCP_AUDIENCE, conventionally https://<instance>/mcp — that theresource. This mattersresource equalsMCP_AUDIENCE set, MCP clients get tokens for https://<instance>/mcp whilehttps://<instance>/api — nothing elsetemper CLI:authorization_code, refresh_tokenhttps://<instance>/api/auth/cli-callbackclient_id is what users supply when running temper init--auth-client-id.https://claude.ai/api/mcp/auth_callback — Claude Desktop (Connectors UI)https://claude.com/api/mcp/auth_callback — Claude Desktop (alternate)http://localhost/mcp/oauth/callback — CLI MCP clients via loopback proxy (opencode)http://127.0.0.1/mcp/oauth/callback — same, IPv4 loopback varianthttp://localhost — temper CLI relay (port-wildcard for temper auth login)https://<instance>/api/auth/mcp-callback — the loopback redirect proxy relayhttps://<instance>/api/auth/cli-callback — the temper CLI login relayhttp://127.0.0.1:<port>/callback ashttps://<instance>/oauth/authorizehttps://<instance>/api/auth/mcp-callback → http://127.0.0.1:<port>/callback.http://localhost/mcp/oauth/callback entry (no port) matches any port via Auth0'sclient_id becomes MCP_CLIENT_ID in your Vercelauth0 CLI:auth0 apis list # → shows identifier (your AUTH_AUDIENCE)
auth0 apps list # → shows client_id for each application@auth0/auth0-mcp-server) provides the same information| Auth0 value | Environment variable | Notes |
|---|---|---|
| Tenant domain | AUTH_ISSUER | https://<tenant>.auth0.com/ — trailing slash required |
| Tenant JWKS endpoint | JWKS_URL | https://<tenant>.auth0.com/.well-known/jwks.json |
| API identifier | AUTH_AUDIENCE | The API audience — validated by the REST API and (with MCP_AUDIENCE unset) the MCP server |
| Auth provider | AUTH_PROVIDER_NAME | Always auth0 |
| API identifier (MCP) | MCP_AUDIENCE | Optional. The MCP surface's own OAuth resource (conventionally https://<instance>/mcp). Unset, it defaults to AUTH_AUDIENCE — see the second API note for why setting it is recommended |
| MCP app client_id | MCP_CLIENT_ID | The MCP native application's client_id |
| Instance base URL | MCP_BASE_URL | https://<instance> — no trailing slash |
| Proxy state secret | MCP_PROXY_SECRET | Not an Auth0 value — you generate it: a random string of at least 32 characters (openssl rand -base64 48). The Auth0 loopback redirect proxy derives its state-token encryption key from this secret alone, so keep it secret and out of source control |
framework override tovercel.json sets "framework": null). Configure the following| Variable | Surface | Required | Notes |
|---|---|---|---|
DATABASE_URL | api, mcp | Yes | Pooled Neon connection string (runtime) |
DATABASE_URL_UNPOOLED | deploy step | Yes | Direct Neon connection string (migrations only) |
AUTH_ISSUER | api, mcp | Yes | https://<tenant>.auth0.com/ — trailing slash required |
JWKS_URL | api, mcp | Yes | https://<tenant>.auth0.com/.well-known/jwks.json |
AUTH_AUDIENCE | api, mcp | Yes | The API audience the REST surface validates (e.g. https://<instance>/api). Boot fails if unset or empty |
AUTH_PROVIDER_NAME | api, mcp | Yes | Set to auth0 |
MCP_AUDIENCE | api, mcp | No | The MCP surface's own OAuth resource (e.g. https://<instance>/mcp). Unset, it defaults to AUTH_AUDIENCE. Set it on both: the MCP server relays each call to the API with the caller's token, so the API must accept it too |
MCP_CLIENT_ID | mcp | Yes | MCP native application client_id |
MCP_BASE_URL | mcp | Yes | https://<instance> — used in OAuth discovery responses |
TEMPER_API_BASE_URL | mcp | Yes | https://<instance> — the relay's target for every tool act. Pin it to this deployment's own API URL in the MCP function's environment (never anywhere else): the relay presents the caller's bearer AND the service credential to that origin. Treated as credential-adjacent, not a tuning knob — the pool refuses redirects, but the origin it points at is trusted by construction |
TEMPER_MCP_SERVICE_SECRET | api, mcp | Yes | The relay's service-to-service credential — the API trusts the X-Temper-Relayed-Surface: mcp attribution carrier ONLY beside a constant-time match against this value. The same generated value on both functions (openssl rand -base64 32); whitespace-trimmed at parse on both sides, so a secret-file trailing newline does not silently degrade the fleet to @web. The API refuses to boot if it overlaps any other shared secret it holds (the distinctness gate), or if it is under 16 characters — a paste placeholder or a committed test constant. Unset on the API, every relayed act attributes @web and the trust gate is simply never armed |
MCP_PROXY_SECRET | mcp | Yes | At least 32 characters (openssl rand -base64 48). Encrypts the state token in the loopback redirect proxy that every externally-fronted instance serves (Auth0 or Okta). Requests that need that key — a loopback /oauth/authorize and the /api/auth/mcp-callback relay — answer 503 naming this variable until it is set, so an MCP CLI client cannot sign in without it. temper auth login and browser clients with an HTTPS callback do not use the proxy's crypto and are unaffected. Set it before the first deployment and redeploy after any change — a function's environment is bound to its deployment. Rotating it fails sign-ins already in flight for up to 10 minutes; they succeed on retry. Not used in the SAML path |
API_BASE_URL | ui | No | Only for the optional web UI (a separate Vercel project); not required for API + MCP + CLI |
TEMPER_EDGE_PROXY_SECRET | ui, mcp | No | Only when the API project rate-limits per IP at the edge. The web UI's server (its reverse proxy and page loaders) and the MCP relay send it as x-temper-edge-proxy on every request they send the API, because both reach it from a few server addresses that would otherwise put all their users in one bucket; the API project's rules exempt requests carrying it. Same value in both places and in the firewall rule. The API and MCP functions refuse to boot if it is under 16 characters, and the API if it equals any other shared secret it holds; the refusal names the variable, never the value. Confers no access, but a leaked value skips the API project's per-IP rules. See Rate-limit an instance at the edge |
BLOB_READ_WRITE_TOKEN | api | Yes | Vercel Blob token — credentials for the binary-blob flow (temper blob …, /api/blobs, the MCP blob_read/blob_manage tools). Unset (with no BLOB_STORE_ID), the blob endpoints and tools answer a disabled refusal naming this variable. Policy knobs, all optional with defaults: BLOB_ENABLED (set false to close the blob doors deliberately — an explicit opt-out that wins even with credentials present; the endpoints then refuse naming this knob and the MCP tools are not advertised; an unrecognized value also fails closed), BLOB_MAX_BYTES (per-blob cap, 64 MB), BLOB_CONTENT_TYPE_ALLOWLIST (comma-separated media types; default png, jpeg, webp, svg, gif, pdf), BLOB_SINGLE_REQUEST_MAX_BYTES (single-request threshold, 4 MB — beyond it the CLI segments automatically), BLOB_UPLOAD_STAGING_TTL_SECONDS (how long a stalled staged upload survives before the reaper sweeps it, 24 hours) |
SENSITIVITY_SWEEP_ENABLED | api | No | Opts the deployment into the sensitivity sweep. Off unless true or 1: the cron call then answers without scanning or logging an error, and only runs erasure's 30-day digest expiry, which has nothing to do on a deployment that never swept. Opting in is not enough on its own, because every detector ships off: enable the ones you want in SQL, by name at a reviewed version or by severity, and try one first with a dry run that writes nothing. DEPLOYING.md has the statements. An unrecognised value leaves the sweep off and logs an error at boot |
SENSITIVITY_SWEEP_SALT | api | When opted in | The key the sensitivity sweep computes every stored hash and fingerprint under; it is passed to the database per tick and never stored there. At least 32 characters (openssl rand -base64 32), and the API refuses to boot if it is shorter or equals any other shared secret. Unset on a deployment that has opted in, the sweep cannot scan: every tick records a failed run (salt_missing) and logs an error. Keep the value stable: changing it re-keys every stored hash, so existing findings stop matching their content. It reaches Postgres as a bind parameter: where you hold a superuser, set log_parameter_max_length = 0. On Neon the owner role can neither set it nor turn on the duration or statement logging that would write the salt. Either way the sweep logs an error while the server's settings could write it |
ENABLE_SWAGGER | api | No | Set true to serve the Swagger UI at /api-docs/ui (with the OpenAPI document at /api-docs/openapi.json). A plain flag: the UI is served on any deployment that sets it, production included |
PORT | api | No | Platform-injected by Vercel; defaults to 3000. Only relevant for local or non-Vercel runs |
SQLX_OFFLINE | build | Yes | Must be true — compile-time SQL checks run against the committed .sqlx/ cache |
CORS_ORIGINS | api | Situational | See note below |
CORS_ORIGINS caveat: This variable is required for any client that callsCORS_ORIGINS is unset, the APItemper-ui does not need it — it reverse-proxies API/MCP trafficCORS_ORIGINS only if you run a separate browser-based*;AS_ISSUER is set.AS_* variables unset — this# AS mode: issuer, JWKS, and audience all restate one instance. Derive, don't retype.
INSTANCE="https://temper.acme.com"
AS_ISSUER="$INSTANCE" # the mode signal
AUTH_ISSUER="$INSTANCE" # == AS_ISSUER
JWKS_URL="$INSTANCE/oauth/jwks" # == $AS_ISSUER/oauth/jwks
AS_AUDIENCE="$INSTANCE/api" # what the AS mints for API flows
AUTH_AUDIENCE="$INSTANCE/api" # == AS_AUDIENCE — what the REST surface validates
MCP_AUDIENCE="$INSTANCE/mcp" # optional — the MCP surface's own resource indicatortemper-AS or external-IdP:vercel logs <deployment-url> | grep 'auth configured'vercel.json atapi/axum.rs, api/mcp.rs,api/internal.rs), 24 routes and 12 crons; the Topology section abovetemper CLI at your instance. The CLI shipstemper init performs the setup.temper inithttps://<instance><tenant>.auth0.comclient_idhttps://<instance>/api)~/.config/temper/config.toml looks like:[cloud]
api_url = "https://<instance>"
[auth]
provider = "auth0"
[[auth.providers]]
name = "auth0"
authorize_url = "https://<tenant>.auth0.com/authorize"
token_url = "https://<tenant>.auth0.com/oauth/token"
client_id = "<cli-app-client-id>"
audience = "https://<instance>/api"
callback_url = "https://<instance>/api/auth/cli-callback"
scopes = ["openid", "profile", "email", "offline_access"]temper init \
--no-interactive \
--instance-url https://<instance> \
--auth-domain <tenant>.auth0.com \
--auth-client-id <cli-app-client-id> \
--auth-audience https://<instance>/apiconfig.toml and are suitable for CI/CD| Variable | Purpose |
|---|---|
TEMPER_API_URL | Override the API base URL |
TEMPER_PROVIDER | Override the auth provider name |
TEMPER_TOKEN | Inject a JWT directly — no OAuth flow, no disk state |
TEMPER_TOKEN alongsideTEMPER_API_URL and no other configuration is needed. The token is used~/.config/temper/auth.json is not read or written.https://<instance>/mcp. OAuth discovery is servedGET /.well-known/oauth-authorization-server — RFC 8414 metadataGET /.well-known/oauth-protected-resource — RFC 9728 metadataPOST /oauth/register — DCR proxy (returns the pre-registered MCP client_id)claude_desktop_config.json):{
"mcpServers": {
"temper": {
"url": "https://<instance>/mcp"
}
}
}JWKS_URL and checks the instance'sAUTH_AUDIENCE and, when set, MCP_AUDIENCE — the sameMCP_CLIENT_ID matchescurl https://<instance>/api/healthtemper auth login and browser clients do not use the proxy's state-token key, soMCP_PROXY_SECRET is set. This check is the one that does:curl -si "https://<instance>/oauth/authorize?response_type=code&state=x\
&client_id=<MCP_CLIENT_ID>&redirect_uri=http://127.0.0.1:1/callback" | head -1HTTP/2 302. A 503 names the variable to set — MCP CLI clients such astemper auth login/authorize endpoint, the provider redirects the authorization/api/auth/cli-callback (a stateless relay), and that relay forwardslocalhost. The CLI then exchanges thetemper auth login always uses a browser redirect.)# Create a resource
temper resource create --type session --title "smoke test"
# List to confirm it landed
temper resource list --type session
# Retrieve it by ref (UUID or decorated slug-<uuid>, printed as `ref`)
temper resource show <ref>