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.
The order the gates fire on every POST /v1/chat/completions. Each gate is controlled by the settings in the sections below.
The three configurations a customer actually picks. Everything else on this page is detail inside one of these columns.
Full cloud routing with the governance record on. This is ctltower.com.
CTLTOWER_AUDIT_REDIS=1/admin# 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=…
Local model only. Fail-closed: refuses to serve (503) if any cloud key is configured.
config.jsonegress policy dialed on its key# 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.
Sensitive stays local; only requests tagged safe go to the best cloud model. Uncertain → local.
CTLTOWER_PROFILE (env)sensitivity-routed (box or per key)# 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
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 field | What it controls | Values |
|---|---|---|
profile | Routing 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_providers | Hard whitelist / soft reorder of providers within a tier's chain. | anthropic, openai, google, xai, local |
default_tier / min_tier | Skip the classifier / quality floor. | simple · moderate · orchestrator |
readonly | This key is a read-only advisor: tool allowlist, deny-all default, reject/strip mode. | {enabled, tool_allowlist, mode} |
egress | Where this key's traffic may go. | local-only · sensitivity-routed · cloud-ok (unset = inherit deployment) |
audit | Whether this key's requests are recorded. | on · off (unset = inherit deployment; new keys default off at mint) |
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).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.
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.
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 onCTLTOWER_AUDIT_REDIS=1 → Upstash listaudit 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.GET /api/admin/audit/verify · scripts/verify-audit.mjs (offline, dependency-free)Deterministic rules (destructive SQL/shell, secrets, PII) — no model call, no setting. Flags ride the audit entry and the response.
X-CtlTower-Risk response header + risk {level, reasons} on the audit entryTool-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).
CTLTOWER_READONLY=1CTLTOWER_READONLY_TOOL_ALLOWLIST=a,b — empty = deny all toolsCTLTOWER_READONLY_MODE=reject|strip — default reject (403, records intent)readonly field on the key (via /admin) — e.g. only the advisor app is read-onlyOnly 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.
CTLTOWER_AIRGAPPED=1 (accepts 1/true/yes/on)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.
CTLTOWER_SENSITIVITY_ROUTING=1; air-gap implies local-onlyegress field on the key — stricter of box + key winsConsumers 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.
GET /v1/audit?limit=N (bearer-scoped; seq + hash on every entry)GET /v1/governance — the calling key's EFFECTIVE policy (box ⊕ key, with source)Which models exist and which one answers. Chain resolution, highest priority first: per-tier env override → per-key profile → env profile → built-in default.
| Provider | Enabled by | Notes |
|---|---|---|
| anthropic | ANTHROPIC_API_KEY | prompt caching, :thinking suffix |
| openai | OPENAI_API_KEY | also serves audio + embeddings tiers |
GEMINI_API_KEY or GOOGLE_API_KEY | data-URL images only | |
| xai | XAI_API_KEY | OpenAI-compatible at api.x.ai |
| local | wizard, or CTLTOWER_LOCAL_BASE_URL | Ollama / vLLM / LM Studio (OpenAI-compatible); local/default → the configured model |
| Routing knob | Values |
|---|---|
CTLTOWER_PROFILE | anthropic-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_THINK | unset = no-op · false/off/no/0 · true/on/yes/1 · any other string (e.g. high) passed through verbatim as think |
What a consumer controls in the request body — everything else above is operator territory.
| Field | Effect |
|---|---|
model | Tier 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.policy | Free-text policy label recorded on the audit entry (e.g. "read-only-advisor") |
| standard OpenAI fields | tools, tool_choice, stream, response_format, max_tokens, temperature, images… |
| Endpoint | Auth | Purpose |
|---|---|---|
POST /v1/chat/completions | consumer bearer | The product. Also /v1/audio/transcriptions, /v1/audio/speech, /v1/embeddings. |
GET /v1/audit | consumer bearer | Caller's own audit trail (scoped), ?limit. |
GET /v1/governance | consumer bearer | The calling key's effective governance policy (deployment merged with per-key, source included). |
GET /api/health | none | {status, version, commit} — healthcheck + build verification. |
GET /api/setup · POST | one-time code | First-boot claim (appliance only; 404 hosted). Writes config + admin token, seals the genesis audit entry. |
/api/admin/keys · /admin | admin token | Mint / list / update / revoke consumer keys + their policies. |
/api/admin/box-config | admin token | Read / change posture + local model post-setup (the redo surface; audit-chained). |
GET /api/admin/audit/verify | admin token | Walks the hash chain; intact / first-break. |
GET /api/config | consumer bearer | Resolved profile, providers, full tier table. |
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_*.
| Variable | What it does | Values / default | Posture |
|---|---|---|---|
CTLTOWER_DATA_DIR | Makes 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-only | air-gap hybrid |
ANTHROPIC_API_KEY et al. | Enables a cloud provider (OPENAI_, GEMINI_/GOOGLE_, XAI_API_KEY). Forbidden under air-gap. | unset = provider off | hosted hybrid |
CTLTOWER_PROFILE | Fleet-default routing profile (a key's own profile overrides it per consumer). Inferred local-only when air-gapped. | anthropic-only | all |
CTLTOWER_TIER_<TIER> | Overrides one tier's chain; beats profile and per-key prefs. | unset | all |
CTLTOWER_LOCAL_BASE_URL | OpenAI-compatible local model endpoint; registers the local provider. Wizard-settable. | unset = local off | air-gap hybrid |
CTLTOWER_LOCAL_MODEL | What local/default resolves to (e.g. qwen2.5-coder:32b). Wizard-settable. | "default" | air-gap hybrid |
CTLTOWER_LOCAL_API_KEY | Bearer for the local server, if it wants one (Ollama ignores it). | optional | air-gap hybrid |
CTLTOWER_LOCAL_THINK | Think-mode knob for local reasoning models, merged into the request body. | unset = omit | air-gap hybrid |
CTLTOWER_AUDIT_LOG | Path → 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.jsonl | air-gap hybrid |
CTLTOWER_AUDIT_REDIS | 1/true → Redis audit sink (Upstash). | unset | hosted |
CTLTOWER_READONLY | Box-wide read-only floor: tools gated by allowlist, default deny-all. Prefer per-key readonly. | off | any |
CTLTOWER_READONLY_TOOL_ALLOWLIST | Comma-separated tool names permitted under the box-wide floor. | empty = deny all | any |
CTLTOWER_READONLY_MODE | reject = 403 on disallowed tool (auditable) · strip = silently remove. | reject | any |
CTLTOWER_AIRGAPPED | Zero-egress kill-switch; only local may serve, 503 if a cloud key exists. Wizard posture sets it. | off | air-gap |
CTLTOWER_SENSITIVITY_ROUTING | Box-wide hybrid gate: only tagged-safe requests reach cloud. Wizard posture sets it; prefer per-key egress for mixed fleets. | off | hybrid |
CTLTOWER_API_KEYS | Env-sourced consumer keys, label:secret,label:secret. Store-minted keys (via /admin) are the normal path. | unset | all |
CTLTOWER_ADMIN_TOKEN | Gates /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 / env | all |
CTLTOWER_KEYSTORE_FILE | Path → local JSON key store (keys + per-key policy). Wins over Redis if both set. | <data-dir>/keys.json | air-gap hybrid |
KV_REST_API_URL / _TOKEN | Upstash Redis — key store, classifier cache, Redis audit sink. | unset = graceful degrade | hosted |
CTLTOWER_COMMIT | Stamps /api/health's commit field off-platform (set by the Windows package and Docker build). | platform SHA | air-gap hybrid |
PORT | Listening port. The on-prem default is 11800 (118.0 — the tower frequency). | 11800 on-prem | air-gap hybrid |
READONLY, AIRGAPPED, SENSITIVITY_ROUTING) accept 1 / true / yes / on. AUDIT_REDIS is stricter: 1 or true only. Empty-string values count as unset.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)
# on the box, while apps use it:
sudo tcpdump -n -i any 'tcp and not host <model-ip>'
# should stay silent. Not promised — observed.