CtlTower · governance control plane · configuration reference

Every knob, one page

CtlTower has two config layers: environment vars describe the box (secrets, hardware, network posture, fleet defaults), and the key store carries each consumer's own policy (routing + governance), dialed in live on the /admin screen. Effective policy is always the stricter of the two — a key can tighten the box's posture, never loosen it. This page is served by CtlTower itself: ctltower.com/config hosted, and http://<box>:11800/config on every on-prem install, offline.

hosted ctltower.com on-prem port 11800 edition appliance 1.0 as of 2026-07-17

Request pipeline

The order the gates fire on every POST /v1/chat/completions. Each gate is controlled by the settings in the sections below.

bearer authconsumer key (store or env) air-gap gate503 if cloud key present read-only gatebox ∧ key allowlists, deny-all egress pinstricter of box + key; uncertain → local route + serveprofile / tiers / fallback audit + risk flaghash-chain entry, X-CtlTower-Risk

Deployment postures

The three configurations a customer actually picks. Everything else on this page is detail inside one of these columns.

Posture 1 · cloud

Hosted Governed

Full cloud routing with the governance record on. This is ctltower.com.

Who: SaaS consumers on the hosted service — convenience tier.
All four cloud providers configured
Audit → Redis sink CTLTOWER_AUDIT_REDIS=1
Danger-flagging (always on, no flag)
Per-key policies dialed on /admin
Air-gapped — off (cloud by definition)
# platform env (Vercel dashboard)
ANTHROPIC_API_KEY=…    OPENAI_API_KEY=…
GEMINI_API_KEY=…       XAI_API_KEY=…
CTLTOWER_PROFILE=cost-optimized
CTLTOWER_AUDIT_REDIS=1
CTLTOWER_ADMIN_TOKEN=…
KV_REST_API_URL=…      KV_REST_API_TOKEN=…
Posture 2 · zero-egress

On-Prem Air-Gapped

Local model only. Fail-closed: refuses to serve (503) if any cloud key is configured.

Who: no-cloud client sites (typically a Windows server). The provable-privacy flagship.
First-boot wizard claims the box (one-time code in the box log)
Posture + model chosen ON SCREEN, stored in config.json
Audit log + key store default into the data folder
Each app's read-only + egress policy dialed on its key
Cloud keys — must be absent (enforced, 503)
# Windows Server (the zip): unzip, then
Install-Service.bat      ← run as administrator
# one-time code: service\ctltower-service.out.log

# Linux (the container):
docker run -d -p 11800:11800 \
  -v ctltower-data:/var/ctltower ctltower
# one-time code: docker logs ctltower

# then: http://<box>:11800/admin → enter code,
# pick AIR-GAPPED, point at the model. Done.
Posture 3 · sensitivity-routed

On-Prem Hybrid

Sensitive stays local; only requests tagged safe go to the best cloud model. Uncertain → local.

Who: the middle market — wants cloud reasoning without leaking sensitive artifacts.
Same wizard install; pick HYBRID on screen
Cloud keys + a cloud CTLTOWER_PROFILE (env)
Routing gate sensitivity-routed (box or per key)
Audit log + key store in the data folder
Air-gapped — off (this mode can't claim zero-egress)
# same install as posture 2; pick HYBRID.
# provider secrets stay env-only (ctltower.env.bat
# on Windows, --env-file on Linux):
ANTHROPIC_API_KEY=…
CTLTOWER_PROFILE=cost-optimized
The asymmetry that drives postures 2 and 3: a wrong "sensitive" tag just uses the local model. A wrong "cloud-eligible" tag leaks. So every default is biased local, and the two on-prem gates fail closed rather than degrade.

Per-key policy (the /admin screen)

Each consumer API key carries its own configuration, edited live on /admin — no redeploy. The env posture above is the fleet default; the key is where a specific application gets dialed in.

Per-key fieldWhat it controlsValues
profileRouting profile for this consumer (overrides the env default).anthropic-only · cost-optimized · quality-first · gemini-first · grok-integrated · opus-managed · local-only
allowed_providers / preferred_providersHard whitelist / soft reorder of providers within a tier's chain.anthropic, openai, google, xai, local
default_tier / min_tierSkip the classifier / quality floor.simple · moderate · orchestrator
readonlyThis key is a read-only advisor: tool allowlist, deny-all default, reject/strip mode.{enabled, tool_allowlist, mode}
egressWhere this key's traffic may go.local-only · sensitivity-routed · cloud-ok (unset = inherit deployment)
auditWhether this key's requests are recorded.on · off (unset = inherit deployment; new keys default off at mint)
The tighten-only rule: effective policy = the stricter of the deployment env and the key. Allowlists intersect, reject beats strip, egress resolves by strictness (local-only > sensitivity-routed > cloud-ok). A key can never grant itself a tool the box denies or an egress the box forbids — and air-gap stays absolute. GET /v1/governance reports the merged result with a source field. Storage is pluggable: Upstash Redis (hosted) or a local JSON file in the data folder (on-prem), so /admin works on-prem too — installable as a desktop app. /admin also carries the first-boot setup wizard (claim the box with the one-time code from its log) and the Box configuration screen (redo: change posture/model post-setup, every change audit-chained; factory reset = delete <data-dir>/admin-token, history preserved).

Governance modes

The governance primitives. Each is independent; postures are just named bundles of these. Read-only and egress exist at BOTH layers — env rows here are the deployment-wide floor; the per-key fields above tighten per consumer.

Tamper-evident audit

on by default on-prem

Every request becomes a sha256 hash-chained entry — edits, deletes, and reorders are detectable. Admin actions are chained too: key mint/update/revoke and box (re)configuration, so a quietly minted key or a silent posture change is impossible. The chain's genesis entry IS the setup wizard's posture declaration.

on-prem
CTLTOWER_AUDIT_LOG=<path> → append-only JSONL (defaults to <data-dir>/audit.jsonl); CTLTOWER_AUDIT_LOG=off → explicit sentinel, disables audit even where the appliance default would otherwise switch it on
hosted
CTLTOWER_AUDIT_REDIS=1 → Upstash list
per-key
audit field on the key (via /admin) — on/off, unset = inherit deployment; new keys default off at mint. Off skips only that key's request entries — admin actions on the key always chain.
verify
GET /api/admin/audit/verify · scripts/verify-audit.mjs (offline, dependency-free)

Danger-flagging

always on

Deterministic rules (destructive SQL/shell, secrets, PII) — no model call, no setting. Flags ride the audit entry and the response.

surface
X-CtlTower-Risk response header + risk {level, reasons} on the audit entry

Read-only enforcement

off by default

Tool-calling is gated at the choke point: allowlist, default deny-all. A non-allowlisted tool never reaches the model or comes back out. Env sets the box-wide floor; a key's own readonly policy tightens it (allowlists intersect).

box-wide
CTLTOWER_READONLY=1
allowlist
CTLTOWER_READONLY_TOOL_ALLOWLIST=a,b — empty = deny all tools
mode
CTLTOWER_READONLY_MODE=reject|strip — default reject (403, records intent)
per-key
readonly field on the key (via /admin) — e.g. only the advisor app is read-only

Air-gapped kill-switch

off by default

Only the local provider may serve. If any cloud key is present, CtlTower refuses (503) — it runs clean or it doesn't run. The claim a security team can verify with a network trace.

enable
wizard posture Air-gapped, or CTLTOWER_AIRGAPPED=1 (accepts 1/true/yes/on)
fail mode
closed — refuses to serve rather than silently egress

Egress / sensitivity routing

off by default

Caller declares metadata.sensitivity; CtlTower enforces. Under sensitivity-routed, only "non-sensitive"/"public" may reach cloud; anything else → pinned to local/default, no cloud fallback. local-only pins everything, tags ignored.

box-wide
wizard posture Hybrid, or CTLTOWER_SENSITIVITY_ROUTING=1; air-gap implies local-only
per-key
egress field on the key — stricter of box + key wins
fail mode
safe-local — uncertainty never leaks; local down = error, never cloud fallback

Governance visibility

always available

Consumers can prove their own posture without admin access — each sees only its own audit entries (including policy changes to its own key), plus the effective policy in force for it.

own trail
GET /v1/audit?limit=N (bearer-scoped; seq + hash on every entry)
posture
GET /v1/governance — the calling key's EFFECTIVE policy (box ⊕ key, with source)

Providers & routing

Which models exist and which one answers. Chain resolution, highest priority first: per-tier env override → per-key profile → env profile → built-in default.

ProviderEnabled byNotes
anthropicANTHROPIC_API_KEYprompt caching, :thinking suffix
openaiOPENAI_API_KEYalso serves audio + embeddings tiers
googleGEMINI_API_KEY or GOOGLE_API_KEYdata-URL images only
xaiXAI_API_KEYOpenAI-compatible at api.x.ai
localwizard, or CTLTOWER_LOCAL_BASE_URLOllama / vLLM / LM Studio (OpenAI-compatible); local/default → the configured model
Routing knobValues
CTLTOWER_PROFILEanthropic-only (default) · cost-optimized · quality-first · gemini-first · grok-integrated · opus-managed · local-only (inferred when air-gapped)
CTLTOWER_TIER_<TIER>provider/model,provider/model — overrides one tier's chain. Tiers: simple moderate reasoning orchestrator classifier transcribe tts embed
CTLTOWER_LOCAL_THINKunset = no-op · false/off/no/0 · true/on/yes/1 · any other string (e.g. high) passed through verbatim as think

Per-request knobs

What a consumer controls in the request body — everything else above is operator territory.

FieldEffect
modelTier name (simple…) routes to that tier · qualified id (anthropic/claude-…) pins, no fallback · ctltower-auto / omit = classifier decides
metadata.sensitivity"sensitive" · "non-sensitive" · "public" — always recorded on the audit entry; drives routing under a sensitivity-routed egress policy
metadata.policyFree-text policy label recorded on the audit entry (e.g. "read-only-advisor")
standard OpenAI fieldstools, tool_choice, stream, response_format, max_tokens, temperature, images…

Endpoints

EndpointAuthPurpose
POST /v1/chat/completionsconsumer bearerThe product. Also /v1/audio/transcriptions, /v1/audio/speech, /v1/embeddings.
GET /v1/auditconsumer bearerCaller's own audit trail (scoped), ?limit.
GET /v1/governanceconsumer bearerThe calling key's effective governance policy (deployment merged with per-key, source included).
GET /api/healthnone{status, version, commit} — healthcheck + build verification.
GET /api/setup · POSTone-time codeFirst-boot claim (appliance only; 404 hosted). Writes config + admin token, seals the genesis audit entry.
/api/admin/keys · /adminadmin tokenMint / list / update / revoke consumer keys + their policies.
/api/admin/box-configadmin tokenRead / change posture + local model post-setup (the redo surface; audit-chained).
GET /api/admin/audit/verifyadmin tokenWalks the hash chain; intact / first-break.
GET /api/configconsumer bearerResolved profile, providers, full tier table.

Env-var index

Every environment variable CtlTower reads. On an appliance most are set by the setup wizard and stored in the data folder; env always wins if set, so these remain the explicit override. CTLTOWER_* is read first; legacy TSKPILOT_* names still work for PROFILE, ADMIN_TOKEN, API_KEYS and TIER_*.

VariableWhat it doesValues / defaultPosture
CTLTOWER_DATA_DIRMakes the box an appliance: audit log, key store, stored config, and admin token all default into this directory; activates the setup wizard. Set by the Windows batch files and the Docker image.unset = env-onlyair-gap hybrid
ANTHROPIC_API_KEY et al.Enables a cloud provider (OPENAI_, GEMINI_/GOOGLE_, XAI_API_KEY). Forbidden under air-gap.unset = provider offhosted hybrid
CTLTOWER_PROFILEFleet-default routing profile (a key's own profile overrides it per consumer). Inferred local-only when air-gapped.anthropic-onlyall
CTLTOWER_TIER_<TIER>Overrides one tier's chain; beats profile and per-key prefs.unsetall
CTLTOWER_LOCAL_BASE_URLOpenAI-compatible local model endpoint; registers the local provider. Wizard-settable.unset = local offair-gap hybrid
CTLTOWER_LOCAL_MODELWhat local/default resolves to (e.g. qwen2.5-coder:32b). Wizard-settable."default"air-gap hybrid
CTLTOWER_LOCAL_API_KEYBearer for the local server, if it wants one (Ollama ignores it).optionalair-gap hybrid
CTLTOWER_LOCAL_THINKThink-mode knob for local reasoning models, merged into the request body.unset = omitair-gap hybrid
CTLTOWER_AUDIT_LOGPath → file audit sink (append-only, hash-chained JSONL). off is an explicit sentinel that disables audit even on an appliance where the data-dir default would otherwise switch it on.<data-dir>/audit.jsonlair-gap hybrid
CTLTOWER_AUDIT_REDIS1/true → Redis audit sink (Upstash).unsethosted
CTLTOWER_READONLYBox-wide read-only floor: tools gated by allowlist, default deny-all. Prefer per-key readonly.offany
CTLTOWER_READONLY_TOOL_ALLOWLISTComma-separated tool names permitted under the box-wide floor.empty = deny allany
CTLTOWER_READONLY_MODEreject = 403 on disallowed tool (auditable) · strip = silently remove.rejectany
CTLTOWER_AIRGAPPEDZero-egress kill-switch; only local may serve, 503 if a cloud key exists. Wizard posture sets it.offair-gap
CTLTOWER_SENSITIVITY_ROUTINGBox-wide hybrid gate: only tagged-safe requests reach cloud. Wizard posture sets it; prefer per-key egress for mixed fleets.offhybrid
CTLTOWER_API_KEYSEnv-sourced consumer keys, label:secret,label:secret. Store-minted keys (via /admin) are the normal path.unsetall
CTLTOWER_ADMIN_TOKENGates /api/admin/* and the /admin UI. On an appliance the setup wizard generates it instead (stored 0600 in the data dir, shown once).wizard-generated / envall
CTLTOWER_KEYSTORE_FILEPath → local JSON key store (keys + per-key policy). Wins over Redis if both set.<data-dir>/keys.jsonair-gap hybrid
KV_REST_API_URL / _TOKENUpstash Redis — key store, classifier cache, Redis audit sink.unset = graceful degradehosted
CTLTOWER_COMMITStamps /api/health's commit field off-platform (set by the Windows package and Docker build).platform SHAair-gap hybrid
PORTListening port. The on-prem default is 11800 (118.0 — the tower frequency).11800 on-premair-gap hybrid
Boolean parsing: the governance toggles (READONLY, AIRGAPPED, SENSITIVITY_ROUTING) accept 1 / true / yes / on. AUDIT_REDIS is stricter: 1 or true only. Empty-string values count as unset.

Verify it yourself

Is it alive & honest?

curl -s $HOST/api/health | jq .commit     # right build?
curl -s -H "Authorization: Bearer $KEY" \
  $HOST/v1/governance | jq                # right posture, for MY key?
node scripts/verify-audit.mjs audit.jsonl # chain intact? (offline)

Is it really air-gapped?

# on the box, while apps use it:
sudo tcpdump -n -i any 'tcp and not host <model-ip>'
# should stay silent. Not promised — observed.