1. guides
temper
  • Temper documentation
  • 2026-07-06-cogmap-telos-differentiation
  • handoffs
  • plans
    • archived
    • evidence
  • reviews
  • specs
  • spikes
  • agents
  • auth
    • Temper auth & security
    • The authorization seam (`temper-services::auth`)
    • Cognitive-map & resource authoring authorization
    • JWT verification
    • The issuer / resource-server boundary & the machine-token contract
    • The internal reconcile channel
  • code-reviews
  • cognitive-maps
  • decisions
  • development
  • guides
    • Building a cognitive map from a large corpus
    • Using temper from Claude Desktop (and claude.ai)
    • Cloud Agent Development Guide
    • Ingesting a corpus into a context
    • Development setup
    • Drain operator queries (TraceQL)
    • Enterprise Install — Ground Up
    • Operator guide: read-only GitHub credential via a BYO App + Vercel Connect
    • Operator guide: provisioning a GitHub connection in temper
    • Installing Temper
    • Delivering L0 Kernel Cogmap Content
    • Machine Credentials
    • OpenTelemetry Setup
    • Operational Memory
    • Bootstrapping a Temper org
    • Releasing Temper
    • Self-Hosting Temper with Okta
    • Self-Hosting Temper with a SAML IdP
    • Self-Hosting Temper
    • The Slack Integration — identity, credentials, and what revocation actually means
    • Operator guide: `@temper` on Slack — end-to-end setup
    • Bootstrapping a team's self-cognition cognitive map
    • Working with Teams
    • Integrating Ruby with temper-rb
    • Deploying an Eve agent to Vercel (the steward and the citation auditor)
    • WS6 Collapsed-Schema Dev Environment
  • research
  • security
  • specs
  • doors
    • Building against Temper
    • Running Temper
    • Using Temper
  • Temper /api/query — the compositional read surface
    • Run a declared composition of situated acts.
    • Schemas
      • Composition
      • OutcomeDeclaration
      • ReturnSpec
      • Intention
      • StageNode
      • ActInvocation
      • CombineNode
      • StageInput
      • StageRelation
      • Disclosure
      • QueryResponse
      • StageResult
      • StageOutput
      • ScoreKind
      • Scoring
      • ResourceHit
      • CompositionTrace
      • StageTrace
      • InputSource
      • NarrowedBy
      • StageName
      • ActName
      • IdKind
      • IdSet
      • BoundTerm
      • Extent
      • StageDisposition
      • ActRefusal
      • PlanRefusal
      • RefusalReason
      • ActQuantity
      • QuantityScale
      • ResourceFilter
      • EdgeFilter
      • PropertyPredicate
      • PropertyOp
      • ResourceSection
      • EdgeKind
      • OrdOp
      • CogmapId
      • ContextId
      • IdProvenance
      • FacetPredicate
      • CombineOp
      • BlockId
      • MatchLocation
      • BodyStorage
      • ResourceId
      • IngestState
      • ManagedMeta
      • ProfileId
      • ResourceView
      • Polarity
      • ViaEntry
      • LensId
      • RegionId
      • CogmapRegionRow
      • RegionHit
      • StageInputTrace
      • Standing
      • ActorAuthority
      • Refusal
      • SystemAccessDetails
      • PlanRefusalDetails
      • ErrorDetails
      • ErrorDetail
      • ErrorBody
  • Temper Cloud API
    • Access
      • POST /api/access/requests — submit a join request for the gating team.
      • GET /api/access/requests/me — check own join request status.
      • DELETE /api/access/requests/me — withdraw a pending join request.
      • POST /api/access/reviews — a revoked principal asks an admin to reconsider (spec D15).
      • GET /api/access/settings — read public system settings.
    • Slack Link
      • Disconnect any principal. Operator path — offboarding and stuck users.
      • Disconnect EVERY Slack principal bound to the caller's own profile.
    • Auditor
      • auditor_dispatch
      • auditor_sweep
      • complete_auditor_job
    • Graph
      • POST /api/cogmaps/{id}/graph/slice — R4 cogmap-scoped neighborhood slice.
      • GET /api/graph/cogmaps/{id}/panorama — enter-a-cogmap Tier-0 interior.
      • GET /api/graph/contexts/composition — Beat E Tier-1: the force-graph composition of a container's (or a residual bucket's) members.
      • GET /api/graph/contexts/panorama — Beat E Tier-0: goal-container territories + residual tray.
      • GET /api/graph/home — the you→teams→cogmaps membership home.
      • GET /api/graph/regions/composition — Beat D region→resources composition drill.
    • Cognitive Maps
      • list_cognitive_maps
      • genesis
      • get_cognitive_map
      • reconcile
      • analytics
      • grant_cogmap_access
      • revoke_cogmap_access
      • materialize
      • materialize_delta
      • region_metrics
      • shape
      • bind_team
      • unbind_team
    • Contexts
      • list_contexts
      • create_context
      • get_context
      • context_materialize
      • reassign
      • context_region_metrics
      • rename
      • context_shape
      • share_team
      • unshare_team
    • Events
      • cursor
      • GET /api/graph/elements/{kind}/{id}/trail — R5 element event-trail. kind ∈ {node, edge}.
    • Facets
      • set_facet
      • Read the live facets of one edge.
      • Set a facet whose owner is an **edge** rather than a resource.
      • Read the live facets of one resource — the confirming read for a write that steers region formation and Atlas grouping.
    • Health
      • health_check
    • Ingest
      • create_ingest
      • update_ingest
      • list_blocks
      • append_block
      • finalize_resource
    • Invitations
      • accept
      • decline
      • list_mine
      • list_team_invitations
      • revoke_team_invitation
      • create_team_invitation
    • Invocations
      • list_invocations
      • open
      • show
      • close
    • Profile
      • get_profile
      • update_profile
      • list_auth_links
    • Query
      • `POST /api/query`.
    • Relationships
      • assert
      • fold
      • retype
      • reweight
    • Resources
      • `GET /api/resources` — **one response type, unconditionally.**
      • create_resource
      • get_resource
      • delete_resource
      • update_resource
      • List the finding at `{id}`'s citation-audit trail — one row per audit, each naming its auditor.
      • Record an auditor's signed defensibility verdict on one `(block, source)` citation of the finding at `{id}`. CONFORM to `handlers::edges::assert` (the sibling authored-write handler): thin — build the command, dispatch it, map the error. No persistence he
      • get_content
      • list_resource_edges
      • resource_evidence
      • grant_resource_access
      • revoke_resource_access
      • resource_lineage
      • provenance
      • annotate_resource
    • Meta
      • get_meta
      • update_meta
    • Reassign
      • reassign_resource
      • reassign_team
    • Search
      • `POST /api/search`.
    • Steward
      • candidates
      • dispatch
      • sweep
      • delta
      • advance
    • Teams
      • list_teams
      • create_team
      • detail
      • delete_team
      • update_team
      • add_member
      • remove_member
      • change_role
    • Schemas
      • AcceptInvitationResponse
      • ActInput
      • ActInvocation
      • ActName
      • ActQuantity
      • ActRefusal
      • ActorAuthority
      • AddMemberRequest
      • AdvanceWatermarkAck
      • AdvanceWatermarkRequest
      • AgentAuthorship
      • AppendBlockPayload
      • AssertRelationshipRequest
      • AtlasEdge
      • AtlasHome
      • AtlasNode
      • AtlasSubgraph
      • AuditCitation
      • AuditSweepRow
      • AuditorDispatchTickRequest
      • AuditorDispatchTickResponse
      • AuditorJobCompleteAck
      • BindTeamOutcome
      • BindTeamRequest
      • BlockId
      • BlockProvenanceRow
      • BlocksResponse
      • BodyStorage
      • Bridge
      • BulkReassignAck
      • BulkReassignRequest
      • ChangeRoleRequest
      • CharterBlock
      • CharterDisposition
      • CitationAuditRequest
      • CitationAuditRow
      • ClaimedAuditJob
      • ClaimedJob
      • CloseInvocationRequest
      • CogmapAnalyticsRow
      • CogmapDetail
      • CogmapFoundationRow
      • CogmapGrantBody
      • CogmapId
      • CogmapRegionMetricsRow
      • CogmapRegionRow
      • CogmapRegulationRow
      • CogmapRevokeBody
      • CogmapRow
      • CogmapStaleness
      • CombineNode
      • CombineOp
      • Composition
      • CompositionTrace
      • ConfidenceBand
      • ContentResponse
      • ContextCreateRequest
      • ContextId
      • ContextOwnerRef
      • ContextPanorama
      • ContextRow
      • ContextRowWithCounts
      • CorrelationId
      • CreateCogmapOutcome
      • CreateCogmapRequest
      • CreateInvitationRequest
      • CreateRequestBody
      • CreateReviewBody
      • DeleteResponse
      • DeviceOverrides
      • DispatchTickRequest
      • DispatchTickResponse
      • Disposition
      • DriftSweepRow
      • EdgeFacetRow
      • EdgeFacetSetRequest
      • EdgeFacetsResponse
      • EdgeFilter
      • EdgeId
      • EdgeKind
      • ElementEvent
      • ElementKind
      • EmbedDispatchSummary
      • Entitlements
      • ErrorBody
      • ErrorDetail
      • ErrorDetails
      • EventCursorResponse
      • EventTrail
      • ExactArm
      • ExactHit
      • Extent
      • FacetAck
      • FacetPredicate
      • FacetSetRequest
      • FinalizePayload
      • FoldRelationshipRequest
      • GrantOutcome
      • GraphEdgeRow
      • GroupKeyMeta
      • HealthResponse
      • HomeCogmap
      • HomeContext
      • IdKind
      • IdProvenance
      • IdSet
      • IdpRevocation
      • IngestCreateResponse
      • IngestDelta
      • IngestPayload
      • IngestState
      • InheritedReadGrant
      • InheritedShare
      • InputSource
      • Intention
      • InvitationStatus
      • InvitationTokenRequest
      • InviteeInvitation
      • InvocationAck
      • InvocationActRow
      • InvocationCloseAck
      • InvocationId
      • InvocationSummary
      • InvocationView
      • JoinRequest
      • JoinRequestStatus
      • LensId
      • LineageNode
      • ManagedMeta
      • MatchLocation
      • MaterializeAck
      • MaterializeDelta
      • MaterializeRequest
      • MergePolicy
      • MetaUpdatePayload
      • NarrowedBy
      • NodeHome
      • OpenInvocationRequest
      • OrdOp
      • OrphanNode
      • OutcomeDeclaration
      • PlanRefusal
      • PlanRefusalDetails
      • Polarity
      • Profile
      • ProfileAuthLink
      • ProfileId
      • ProfileUpdateRequest
      • ProfileWithEntitlements
      • PropertyOp
      • PropertyPredicate
      • ProvenanceSource
      • PublicSystemSettings
      • QuantityScale
      • QueryResponse
      • ReassignAck
      • ReassignContextOutcome
      • ReassignContextRequest
      • ReassignResourceRequest
      • ReconcileCogmapRequest
      • ReconcileEdge
      • ReconcileEdgeTombstone
      • ReconcileEntry
      • ReconcileOutcome
      • ReconcileTelos
      • ReconcileTelosBlock
      • ReconcileTombstone
      • Refusal
      • RefusalReason
      • RegionHit
      • RegionId
      • RelationshipAck
      • RemoveMemberOutcome
      • RenameContextOutcome
      • RenameContextRequest
      • ResidualBucket
      • ResidualContext
      • ResidualGroups
      • ResidualOwnedReach
      • ResourceAnnotateRequest
      • ResourceCreateRequest
      • ResourceFacetRow
      • ResourceFacets
      • ResourceFacetsResponse
      • ResourceFilter
      • ResourceGrantBody
      • ResourceHit
      • ResourceId
      • ResourceLineage
      • ResourceListResponse
      • ResourceRevokeBody
      • ResourceSection
      • ResourceSortField
      • ResourceUpdateRequest
      • ResourceView
      • ReturnSpec
      • RetypeRelationshipRequest
      • RevokeOutcome
      • ReweightRelationshipRequest
      • ScoreKind
      • Scoring
      • SearchParams
      • SearchReason
      • SearchResponse
      • SearchResultRow
      • SearchScope
      • SearchScopeInfo
      • SegmentInfo
      • SegmentedBegin
      • SegmentedBeginResponse
      • ShareContextOutcome
      • ShareContextRequest
      • SlackDisconnectRequest
      • SlackDisconnectResponse
      • SlackDisconnectedPrincipal
      • SliceRequest
      • SortOrder
      • StageDisposition
      • StageInput
      • StageInputTrace
      • StageName
      • StageNode
      • StageOutput
      • StageRelation
      • StageResult
      • StageTrace
      • Standing
      • StandingShape
      • Subscription
      • SubscriptionOverride
      • SystemAccessDetails
      • TeamCreateRequest
      • TeamDetail
      • TeamInvitation
      • TeamMemberDetail
      • TeamMemberRow
      • TeamMemberSource
      • TeamRole
      • TeamRow
      • TeamUpdateRequest
      • Territory
      • TerritoryKind
      • TerritoryOverview
      • UnbindTeamOutcome
      • UnshareContextOutcome
      • VaultConfig
      • ViaEntry
      • WideArm
      • WideHit
  1. guides

Self-Hosting Temper

This guide is for operators standing up their own Temper instance — on their own Vercel project, Neon database, and Auth0 tenant — rather than using the hosted service at temperkb.io.
Scope: This runbook covers the API + MCP surfaces, plus an optional web UI. The temper-ui web application (SvelteKit) deploys as its own Vercel project with its own confidential OIDC client; it is fully config-driven (no per-org fork) and is documented in the UI section below.
Doing a full ground-up enterprise install? This guide is one phase. For the single
end-to-end sequence (deploy → SAML → org → agents) see enterprise-install.md.

Topology#

One Vercel project hosts two Rust services from a single deployment:
                           Vercel
                    ┌──────────────────────────────────┐
 CLI / MCP client   │                                  │
 ──────────────────▶│  /.well-known/*  ─┐              │
 temper resource    │  /oauth/*         ├─▶ api/mcp    │
 temper login       │  /mcp             │   (MCP srv)  │
                    │  /mcp/*          ─┘              │
                    │                                  │
                    │  /(.*)           ────▶ api/axum  │
                    │                       (REST API) │
                    └──────────────────────────────────┘
                               │                │
                               ▼                ▼
                           Neon PG 17       Auth0 tenant
                           (pgvector)       (JWT issuer)
Both services share the same database and Auth0 tenant. The routing lives in vercel.json at the repo root:
handle: filesystem runs first (static files if any).
/mcp, /mcp/*, /oauth/*, /.well-known/* route to api/mcp (the MCP server).
/(.*) (catch-all) routes to api/axum (the REST API).
framework is null; there is no framework-level routing. SQLX_OFFLINE=true is set in the build environment so the Rust macros compile against the committed .sqlx/ cache rather than a live database.

Provision Neon#

Create a new Neon project. Select PostgreSQL 17 (Neon's GA version — the local dev Docker image runs 18, but the cloud deployment targets 17).

Enable extensions#

Open a SQL console on the 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 by default and does not require an explicit CREATE EXTENSION.

Capture connection strings#

From the Neon console, copy both connection strings:
Pooled URL (DATABASE_URL) — the host contains -pooler. Used at runtime.
Direct URL (DATABASE_URL_UNPOOLED) — no -pooler suffix. Used for migrations only.
Both take the form:
postgresql://<user>:<password>@<host>/neondb?sslmode=require&channel_binding=require

Run migrations#

Migrations are a deploy step, not a startup step. The API server does not auto-migrate on boot. After setting your Vercel environment variables (see below), run migrations against the direct URL from your local machine or a CI job:
DATABASE_URL=<DATABASE_URL_UNPOOLED> sqlx migrate run
Migration files live in migrations/. sqlx is the single migration authority — never apply schema changes by other means.
Migrations provision the schema. Some content is delivered separately as an
operator step — notably the L0 kernel cogmap's landmarks + telos charter, which
is admin-gated and fail-closed. See
l0-content-delivery.md if you need a populated L0 map
on your instance.

Neon × Vercel integration#

If you connect your Neon project to Vercel via the Neon integration, Neon automatically provisions DATABASE_URL and DATABASE_URL_UNPOOLED per preview branch. Pull-request preview deployments therefore get isolated databases with no manual wiring. The migration step still runs separately — Vercel does not run it automatically.

Provision Auth0#

Using Okta instead? This section is Auth0-specific. For standing up the same instance against
an Okta tenant in an enterprise context, see Self-Hosting with Okta —
it covers the custom authorization server, API Access Management requirement, and the
Okta-specific environment and CLI configuration. The rest of this guide (Neon, Vercel, verify)
applies unchanged.
The contract is one API resource server and two native applications (plus an optional confidential web-app client if you deploy the web UI):

1. API resource server#

Create an API in Auth0. The identifier you assign becomes the OAuth audience — the one audience your instance validates, on both surfaces. It appears as AUTH_AUDIENCE in your Vercel environment. A conventional value is https://<instance>/api. See Auth identity: the variables that must agree for the full contract, including the optional MCP_AUDIENCE restatement.

2. CLI native application#

Create a Native application for the temper CLI:
Grant types: authorization_code, refresh_token
Allowed callback URL: https://<instance>/api/auth/cli-callback
The application's client_id is what users supply when running temper init with --auth-client-id.

3. MCP native application#

Create a second Native application for MCP clients (e.g. Claude Desktop):
Allowed callbacks: callback URLs for the MCP clients you support (e.g. https://claude.ai/api/mcp/auth_callback, https://claude.com/api/mcp/auth_callback, http://localhost).
This application's client_id becomes MCP_CLIENT_ID in your Vercel environment.

Reading values from a live tenant#

If you already have a tenant configured, you can enumerate its values with the auth0 CLI:
auth0 apis list           # → shows identifier (your AUTH_AUDIENCE)
auth0 apps list           # → shows client_id for each application
The Auth0 MCP server (@auth0/auth0-mcp-server) provides the same information in an agentic session.

Env var mapping#

Auth0 valueEnvironment variableNotes
Tenant domainAUTH_ISSUERhttps://<tenant>.auth0.com/ — trailing slash required
Tenant JWKS endpointJWKS_URLhttps://<tenant>.auth0.com/.well-known/jwks.json
API identifierAUTH_AUDIENCEThe one audience — validated by both the REST API and the MCP server
Auth providerAUTH_PROVIDER_NAMEAlways auth0
API identifier (MCP)MCP_AUDIENCEOptional. If set, it must equal AUTH_AUDIENCE — it restates the one audience, it does not add a second one
MCP app client_idMCP_CLIENT_IDThe MCP native application's client_id
Instance base URLMCP_BASE_URLhttps://<instance> — no trailing slash

Deploy to Vercel#

Import the repository into a new Vercel project. Set framework override to Other (the vercel.json sets "framework": null). Configure the following environment variables in the Vercel project dashboard before the first deployment.

Environment variable contract#

VariableSurfaceRequiredNotes
DATABASE_URLapi, mcpYesPooled Neon connection string (runtime)
DATABASE_URL_UNPOOLEDdeploy stepYesDirect Neon connection string (migrations only)
AUTH_ISSUERapi, mcpYeshttps://<tenant>.auth0.com/ — trailing slash required
JWKS_URLapi, mcpYeshttps://<tenant>.auth0.com/.well-known/jwks.json
AUTH_AUDIENCEapi, mcpYesThe one audience both surfaces validate (e.g. https://<instance>/api). Boot fails if unset or empty
AUTH_PROVIDER_NAMEapi, mcpYesSet to auth0
MCP_AUDIENCEapi, mcpNoAn optional restatement of AUTH_AUDIENCE. If set it must equal it; unset is the normal configuration
MCP_CLIENT_IDmcpYesMCP native application client_id
MCP_BASE_URLmcpYeshttps://<instance> — used in OAuth discovery responses
API_BASE_URLuiNoOnly for the optional web UI (a separate Vercel project); not required for API + MCP + CLI
BLOB_READ_WRITE_TOKENapiYesVercel Blob token — used by the upload/extract/embed pipeline
ENABLE_SWAGGERapiNoSet true to expose /swagger-ui in non-production deployments
PORTapiNoPlatform-injected by Vercel; defaults to 3000. Only relevant for local or non-Vercel runs
SQLX_OFFLINEbuildYesMust be true — compile-time SQL checks run against the committed .sqlx/ cache
CORS_ORIGINSapiSituationalSee note below
CORS_ORIGINS caveat: This variable is required for any client that calls the API cross-origin from a browser. When CORS_ORIGINS is unset, the API returns no CORS headers and cross-origin requests fail. Note the bundled temper-ui does not need it — it reverse-proxies API/MCP traffic same-origin through its own server (see Deploy the UI), so the browser never makes a cross-origin call. Set CORS_ORIGINS only if you run a separate browser-based client against the API directly. A permissive development value is *; production should list only the specific origins that need access.

Auth identity: the variables that must agree#

Audience: every operator standing up an instance — Auth0, Okta, or Temper's own AS.
Scope: the six variables that decide whose tokens this instance trusts and which tokens it accepts. Six variables, but not six decisions — read the table below as one identity spelled several ways.
Your instance runs in exactly one of two modes, and the mode is decided by a single signal: whether AS_ISSUER is set.
External IdP — AS_ISSUER unset. Auth0 or Okta mints tokens; Temper is a pure resource server. This is the shape the rest of this guide assumes, and the shape temperkb.io runs.
Temper AS — AS_ISSUER set. Temper's own authorization server mints tokens: the mode that backs SAML and temper-issued machine credentials.
VariableExternal IdP (AS_ISSUER unset)Temper AS (AS_ISSUER set)
AUTH_ISSUERThe IdP's issuer URL — https://<tenant>.auth0.com/Your instance's origin — must equal AS_ISSUER
JWKS_URLThe IdP's JWKS endpoint — https://<tenant>.auth0.com/.well-known/jwks.jsonMust be $AS_ISSUER/oauth/jwks — the AS publishes its own keys
AUTH_AUDIENCEThe IdP's API identifier. The one audience, validated on both surfacesMust equal AS_AUDIENCE
MCP_AUDIENCEOptional. If set, must equal AUTH_AUDIENCE; unset is normalOptional. Same rule
AS_ISSUERLeave unset — setting it flips the instance into AS modeRequired. Your instance's origin (no trailing slash needed) — its presence is the mode signal
AS_AUDIENCELeave unset — never read in this modeRequired. Must equal AUTH_AUDIENCE
Trailing slashes are normalized before every comparison — Auth0 issuers conventionally end in / and the AS's own metadata strips them, so https://temper.acme.com and https://temper.acme.com/ are the same issuer as far as the gate is concerned.
On an AS instance the three audiences are one value spelled three ways — not three independent knobs. The Temper AS mints every token, human and machine, with the single server-side AS_AUDIENCE, ignoring any request-supplied audience (packages/temper-cloud/src/oauth/mint.ts). So AS_AUDIENCE is the audience minted, AUTH_AUDIENCE the audience validated, and MCP_AUDIENCE — if you set it at all — merely restates it. They are the same string or the instance verifies nothing. Under an external IdP there is no AS, so AS_* is unset entirely: that is why the agreement rules are mode-dependent, and why no operator should be expected to hold them in their head.
In AS mode, derive the values from the instance origin rather than retyping them — the five variables carry two facts, and a typo in any one of them is a typo in a fact you already stated:
# 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
AUTH_AUDIENCE="$INSTANCE/api"             # == AS_AUDIENCE — what both surfaces validate
An incoherent auth config fails the boot. Both surfaces parse this identity once, at startup, through the same code (crates/temper-services/src/auth_config.rs), and an instance that violates any rule above refuses to start — naming the offending variable and the relation it must satisfy (it never prints a value). That is deliberate. The old behavior was a warn line and a served request; a warning in a serverless log is not a control.
The gate's messages are prescriptive, e.g. "MCP_AUDIENCE is set but does not equal AUTH_AUDIENCE. This instance validates one audience on both surfaces. Set them to the same value, or unset MCP_AUDIENCE." Boot also logs which mode it resolved — an operator who cannot tell which mode their instance is in is precisely the operator who mis-sets these variables:
# Confirm the mode the instance booted in — "temper-AS" or "external-IdP"
vercel logs <deployment-url> | grep 'auth configured'
These are not new constraints. A working AS instance already satisfied every one of them: a divergent audience verifies nothing, a divergent issuer trusts the wrong party, and a misdirected JWKS_URL checks no signature against the keys that actually signed the token. Temper now names rules that were already true, and fails fast when they are not — which is why a hard boot failure cannot break a working deployment. It can only refuse to start one that was already broken and had not noticed.

What breaks if they disagree#

The old failures were quiet, and they were quiet in opposite directions — one typo, two behaviors:
MisconfigurationOld behaviorNow
AUTH_AUDIENCE unset or emptyThe REST API set validate_aud = false and accepted any token from the issuer, regardless of which API it was minted for — audience validation silently offBoot refuses
MCP_AUDIENCE emptyThe MCP server enforced aud == "" and rejected every tokenAn empty value is treated as absent, uniformly — the instance's one audience still applies
MCP_AUDIENCE set to something elseThe two surfaces validated two different audiencesBoot refuses
AS_AUDIENCE ≠ AUTH_AUDIENCE (AS mode)Every AS-minted token 401s at the resource serverBoot refuses
AUTH_ISSUER ≠ AS_ISSUER (AS mode)The API trusts a party that mints none of its tokensBoot refuses
JWKS_URL not the AS's (AS mode)No signature is ever checked against the keys that signed the tokenBoot refuses
Every one of those used to surface as a caller's 401 — or, worse, as a missing 401 — days later, far from the variable that caused it. Now it surfaces as a loud refusal at startup, with the variable named.

vercel.json summary#

The routing contract at the repo root is:
{
  "framework": null,
  "build": { "env": { "SQLX_OFFLINE": "true" } },
  "routes": [
    { "handle": "filesystem" },
    { "src": "/mcp",          "dest": "/api/mcp" },
    { "src": "/mcp/(.*)",     "dest": "/api/mcp" },
    { "src": "/oauth/(.*)",   "dest": "/api/mcp" },
    { "src": "/.well-known/(.*)", "dest": "/api/mcp" },
    { "src": "/(.*)",         "dest": "/api/axum" }
  ]
}
Do not modify this file unless you are also updating api/axum.rs or api/mcp.rs.

Configure the CLI#

After deploying, users point the temper CLI at your instance. The CLI ships unconfigured; temper init performs the setup.

Interactive setup#

temper init
Select self-hosted at the instance-type prompt. You will be asked for:
1.
Instance URL — https://<instance>
2.
Auth0 domain — <tenant>.auth0.com
3.
Auth0 client ID — the CLI native application's client_id
4.
Auth0 audience — the API identifier (e.g. https://<instance>/api)
The resulting ~/.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"]

Headless / scripted setup#

For CI pipelines or automated provisioning, skip the interactive prompts:
temper init \
  --no-interactive \
  --instance-url https://<instance> \
  --auth-domain <tenant>.auth0.com \
  --auth-client-id <cli-app-client-id> \
  --auth-audience https://<instance>/api

Environment variable overrides#

These variables take precedence over config.toml and are suitable for CI/CD and headless agent contexts:
VariablePurpose
TEMPER_API_URLOverride the API base URL
TEMPER_PROVIDEROverride the auth provider name
TEMPER_TOKENInject a JWT directly — no OAuth flow, no disk state
For a fully headless agent session, export TEMPER_TOKEN alongside TEMPER_API_URL and no other configuration is needed. The token is used in-memory; ~/.config/temper/auth.json is not read or written.

Connect MCP Clients#

Point MCP clients at https://<instance>/mcp. OAuth discovery is served automatically:
GET /.well-known/oauth-authorization-server — RFC 8414 metadata
GET /.well-known/oauth-protected-resource — RFC 9728 metadata
POST /oauth/register — DCR proxy (returns the pre-registered MCP client_id)
Clients that support OAuth 2.0 dynamic client registration will discover the authorization server automatically from the well-known endpoints.
For manual configuration (e.g. Claude Desktop's claude_desktop_config.json):
{
  "mcpServers": {
    "temper": {
      "url": "https://<instance>/mcp"
    }
  }
}
The MCP server validates JWTs against JWKS_URL and checks the instance's one audience — AUTH_AUDIENCE, the same value the REST API validates (see Auth identity). Ensure MCP_CLIENT_ID matches the Auth0 native application registered for your MCP clients and that the client's callback URLs are allowlisted in that Auth0 application.

Deploy the UI (optional)#

The temper-ui SvelteKit app is an optional browser front-end. It deploys as a second Vercel project from the same monorepo (root directory packages/temper-ui) and talks to the API instance you stood up above. It is single-repo and config-driven: an operator points it at their own API origin and their own OIDC issuer entirely through environment variables — no source edits, no fork.

Two couplings, both env-driven#

Browser-facing API/MCP/OAuth traffic is reverse-proxied by the UI's server (hooks.server.ts) to API_BASE_URL, rather than via a hardcoded vercel.json rewrite. Requests to /api/*, /mcp, /oauth/*, and /.well-known/* on the UI origin are forwarded server-side to your API host. Because this is a same-origin proxy (the browser only ever talks to the UI origin), the UI does not require CORS_ORIGINS on the API for its own traffic.
⚠️ API_BASE_URL must be the API backend's own origin, not the UI's public origin. If the UI and API share a public domain (e.g. the UI serves both temperkb.io and proxies temperkb.io/api), pointing API_BASE_URL at that shared domain makes the proxy forward to itself — an infinite loop the platform terminates with 508 Loop Detected. Set it to the distinct origin where the API actually runs (its own *.vercel.app URL, or a dedicated api. subdomain). The UI guards against this and returns a clear 500 rather than looping, but the value still needs to be correct for the proxy to work.
Login is generic OIDC Authorization Code + PKCE. Endpoints are resolved from OIDC_ISSUER's discovery document (/.well-known/openid-configuration), so any OIDC provider works. Logout uses the standard RP-initiated end_session_endpoint.

Register a confidential OIDC client#

In your identity provider, register a Regular Web Application (confidential client) for the UI, distinct from the CLI/MCP native apps:
Allowed callback / redirect URI: https://<ui-host>/auth/callback
Allowed logout / post-logout redirect URI: https://<ui-host>
Grant types: Authorization Code + Refresh Token (the UI requests the offline_access scope)

Environment variable contract (UI project)#

VariableRequiredNotes
API_BASE_URLYesThe API backend's own origin (not the UI's public origin — see the loop warning above), e.g. https://<api-host> — used by server loaders and the browser-facing reverse proxy
OIDC_ISSUERYes¹Issuer base URL, e.g. https://<tenant>.auth0.com or https://<org>.okta.com/oauth2/<asId>. Discovery resolved from <issuer>/.well-known/openid-configuration
OIDC_CLIENT_IDYes¹The UI confidential web-app client_id
OIDC_CLIENT_SECRETYes¹The UI confidential web-app client secret
OIDC_AUDIENCESituationalRequired for Auth0 (the API identifier); omit for Okta custom auth servers, which carry it implicitly
APP_URLYesThe UI's own public origin, e.g. https://<ui-host> — used to build the redirect and post-logout URIs
SESSION_SECRETYes≥32 bytes of entropy (64-char hex or 44-char base64) — derives the JWE session-cookie key
¹ Back-compat fallback: if OIDC_* are unset, the UI falls back to the canonical deployment's AUTH0_DOMAIN / AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRET / AUTH0_AUDIENCE (with OIDC_ISSUER derived as https://<AUTH0_DOMAIN>). Self-hosters should set the OIDC_* variables directly; the fallback exists so the hosted temperkb.io project keeps working unchanged. A non-Auth0 provider is exercised end to end in self-hosting-okta.md.

Verify#

Run these checks after the first deployment and migration.

Health check#

curl https://<instance>/api/health
A healthy response is HTTP 200 with a JSON body. A 500 or connection error typically indicates a missing environment variable or a failed migration.

CLI login#

temper login
This runs the OAuth 2.0 Authorization Code + PKCE flow: it opens a browser to the provider's /authorize endpoint, the provider redirects the authorization code to /api/auth/cli-callback (a stateless relay), and that relay forwards the code to a short-lived listener on localhost. The CLI then exchanges the code for tokens, prints a confirmation, and caches the token locally. (There is no device-code polling — temper login always uses a browser redirect.)

End-to-end resource round-trip#

# 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>
A successful round-trip confirms that the API, database writes, and read-back path are all working against your instance.

Not Covered / Deferred#

The following are outside the scope of this runbook:
Multi-region or HA Neon — This guide targets a single Neon project in one region. Neon's branching and read-replica features are not covered.
Alternative messaging backends — The deployment described here uses the default messaging configuration. RabbitMQ and other transports are not covered.
Single-instance self-hosting (one Vercel project + one Neon project + one Auth0 tenant) is the supported target today.
Modified at 2026-08-19 17:59:52
Previous
Self-Hosting Temper with a SAML IdP
Next
The Slack Integration — identity, credentials, and what revocation actually means
Built with