Deployment

Nothing leaves the box

Route model traffic to runners on your own network, with outbound telemetry, usage reporting and entitlement validation disabled. Fail-closed: no cloud fallthrough, ever.

Enterprise licence required403 pretense_enterprise_required

Air-gap mode is gated on a signed licence JWT at ~/.pretense/licence.jwt. Without it the proxy still boots and still prints the air-gap banner, but every request is refused with 403. On a free-tier install, every command on this page returns pretense_enterprise_required.

That is fail-closed and deliberate. A missing licence never degrades into sending your traffic to a cloud provider. Talk to us about an Enterprise licence.

Checked against the published @pretense/cli@0.6.52 on 2026-08-13 — the build you get from npm i -g @pretense/cli, not the source tree. Where a claim is not verified, it says so.

1. Prerequisites

A licence file — not a YAML tier

Air-gap reads a signed JWT at ~/.pretense/licence.jwt. It is not unlocked by plan.tier: enterprise in pretense.yaml, and the refusal is 403, not 402. Guidance describing a YAML tier plus a pt-ent-* request key applies to the mutation quota, not to air-gap.

Without the licence file the proxy boots, prints the banner, and refuses everything:

response — unlicensed install
HTTP 403
{"error":{"type":"pretense_enterprise_required",
  "message":"Air-gap mode (local model routing) requires an Enterprise licence. Resolved tier is \"free\". Install a signed enterprise licence at ~/.pretense/licence.jwt, or disable airgap.enabled to use a hosted provider.",
  "tier":"free","licensed":false}}

A runner on the OpenAI-compatible surface

terminal
ollama serve            # binds 127.0.0.1:11434
ollama pull llama3.2

Confirm the runner answers on /v1/chat/completions before involving Pretense — that is the only surface Pretense speaks to:

terminal
curl http://127.0.0.1:11434/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"llama3.2","messages":[{"role":"user","content":"hi"}]}'

Ollama, LM Studio, vLLM, llama.cpp's llama-server and LocalAI all expose this surface. Pretense does not have per-runner presets: you point baseUrl at whichever one you run, and it is the URL that matters, not the product name.

2. Configuration

Flip the switch, then declare where your model lives.

terminal
pretense config set airgap.enabled true✓ Set airgap.enabled = true in ./pretense.yaml

Exit 0. It creates pretense.yaml if there is not one yet, so it works before pretense init. It does not add a runner — do that by hand:

pretense.yaml
# pretense.yaml
airgap:
  enabled: true
  policy: secrets-pii-only      # or: full
  runners:
    - name: local-ollama
      baseUrl: http://127.0.0.1:11434
      protocol: openai-compatible
      models: ["llama*", "qwen*", "deepseek*", "gemma*", "mistral*"]

protocol: openai-compatible is the only supported value, and it is the default. ollama-native is refused at boot and the whole runner is dropped— it will simply be missing from the banner's upstream list. Ollama's OpenAI-compatible API is on the same host and port, so the fix is to delete the line.

airgap.enabled: true with zero valid runners rejects every request with pretense_no_local_runner. A typo in baseUrl is a hard stop, not silent cloud egress.

Which model names reach your runner

Routing is by model name, matched against the models: globs. Omit models: and you get these six:

qwen*llama*deepseek*gemma*mistral*codestral*

Each glob compiles to an anchored, case-insensitive regex — ^llama.*$, not .*llama.*. The glob has to be a prefix of the tag. Real tags, and where they land:

Model tag you pulledMatched byReaches your runner
llama3.2llama*yes
llama3.3:70bllama*yes
qwen2.5-coderqwen*yes
deepseek-r1:8bdeepseek*yes
deepseek-coder-v2deepseek*yes
gemma2:9bgemma*yes
gemma3:27bgemma*yes
mistral-nemomistral*yes
codestralcodestral*yes
codellamano default glob — does not start with llamaneeds an entry
mixtralno default glob — does not start with mistralneeds an entry
qwqno default glob — Qwen's reasoning line drops the qwen prefixneeds an entry
phi4no default glob — no phi glob in the defaultsneeds an entry

An explicit models: list replaces the six defaults, it does not extend them. If you add ["codellama*"] to route CodeLlama, you have just stopped routing llama3.2 — spell out every family you use. An unmatched model is refused with pretense_no_local_runner, which is the fail-closed outcome, not a cloud call.

3. Run

terminal
PRETENSE_UPSTREAM_ALLOW_LOCAL=1 pretense start# or, to launch a tool through it in one step:PRETENSE_UPSTREAM_ALLOW_LOCAL=1 pretense run claude

PRETENSE_UPSTREAM_ALLOW_LOCAL=1 re-permits cleartext http:// loopback, which the SSRF and egress guard blocks by default. The proxy prints a loud warning when you set it. Correct for a runner on your own machine; never set it to reach something you do not control.

A model name is not a command. pretense run takes the name of an executable to launch. On 0.6.52, pretense run llama3.2 starts the proxy and then fails with x "llama3.2" is not on your PATH. — there is no built-in chat for a model name in this release. Route the model by pointing a client at the proxy, as below.

Do not reach for the runner's own CLI instead. ollama run llama3.2 talks straight to 127.0.0.1:11434 and never touches Pretense: no mutation, no reversal, no audit row. Your secrets arrive at the model verbatim. Air-gapped is not the same thing as safe — a local runner is still an upstream, and localhost is not an exemption.

Any OpenAI-compatible client works — put the model tag in the request and let the globs route it:

terminal
curl http://localhost:9339/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"llama3.2","messages":[{"role":"user","content":"hi"}]}'

Read the boot banner

stderr
[pretense] AIR-GAP MODE — outbound telemetry, usage reporting and entitlement
validation are disabled. Reachable upstreams: local-ollama=http://127.0.0.1:11434
What you seeWhat it means
Runner missing from that listIts config was dropped at boot — malformed baseUrl, missing name, or protocol: ollama-native. The reason is on stderr.
No banner at allAir-gap never engaged. Check airgap.enabled and which pretense.yaml was loaded.
Banner present but every request 403sThe licence file is missing. The banner prints regardless of licence state, so the banner alone does not mean air-gap is working.

The banner prints regardless of licence state, so the banner alone does not mean air-gap is working.

4. Verifying it actually works

Two checks. Each one can fail, which is the point.

Negative test — no cloud fallthrough

Ask for a model that matches no glob. It must be refused without dispatching anywhere:

terminal
curl -s http://localhost:9339/v1/messages \
  -H 'content-type: application/json' -H 'anthropic-version: 2023-06-01' \
  -d '{"model":"claude-opus-4-20250514","max_tokens":64,
       "messages":[{"role":"user","content":"hi"}]}'

Confirm it at the network layertcpdump, or a firewall deny rule on api.anthropic.com and api.openai.com— rather than trusting the proxy's own output. Silent fallthrough is the failure mode that matters, and a proxy that has been tampered with is not a trustworthy witness to its own egress.

On an unlicensed install the licence gate returns 403 before model routing runs, so a 403 here proves the licence gate, not the glob matching. Proving glob rejection specifically needs a valid licence.

Credential test — read the bytes

Ollama will not show you request headers, so point a runner at something that will:

terminal
nc -l 11434

Send one request and read what arrives. Authorization and x-api-key must be absent— Pretense withholds the caller's provider credential from local runners (TASK-605, shipped in 0.6.51). This is the one test where success proves nothing on its own: a leaked header still produces a perfectly working request. You have to look at the bytes.

Then confirm your build contains the fix:

terminal
npm ls -g @pretense/cli            # must be >= 0.6.51
grep -c isLocalRunner "$(npm root -g)/@pretense/cli/dist/index.js"

0.6.50 and earlier forward the caller's bearer token to local runners over cleartext loopback. Check the installed tree, not the source tree — they drift.

5. Known issues

Open against 0.6.52. Listed so you do not debug them twice.

IssueDetailTicket
/health reports version: "0.2.0"A hardcoded string left on a 0.6.51+ build. /health cannot be used to tell which build is deployed — use pretense --version.TASK-622
--dashboard-port ignoredThe banner prints the default 9340 and the dashboard binds neither port.TASK-623

There is no dedicated air-gap flag and no doctor-style preflight command. Air-gap is configured entirely through pretense.yaml plus the licence file, which is why this page spends so long on the banner.

Was this page helpful?