# ChatPanel Privacy Gateway

A localhost server that puts **ChatPanel's PII redaction / pseudonymization in the
middle of two CLI agents** — so you can use the privacy features outside the
ChatPanel extension. Point [opencode](https://opencode.ai) / pi at the gateway,
and it drives **codex / Claude Code behind your existing subscription login**
(via the [bridge](https://github.com/chatpanel/chatpanel-bridge)), redacting on
the way out and restoring on the way back. The model only ever sees opaque
placeholders like `[[PERSON_1]]` / `[[EMAIL_2]]` — **the real values never leave
your machine.**

```
  opencode / pi   (configured with a custom provider → the gateway)
        │   baseURL → http://127.0.0.1:4320/v1
        ▼
  ┌──────────────────────────────────────────────┐
  │  ChatPanel Privacy Gateway                    │
  │   1. detect + redact   →  [[PERSON_1]] …       │
  │   2. drive the agent behind your login        │
  │   3. restore placeholders in the reply         │
  └──────────────────────────────────────────────┘
        │   POST /chat  (bridge: subscription-authed CLI)
        ▼
  chatpanel-bridge ──spawns──▶ codex / claude   (your ChatGPT / enterprise / Claude login)
```

Two backends (config `backend`):

- **`bridge`** (default) — drive the bridge's subscription-authed CLI agents
  (`codex` / `claude` / `opencode` / `pi`). No API keys, uses your login. This is
  the "privacy bridge between two agents" path above.
- **`api`** — forward redacted traffic to a native OpenAI/Anthropic-compatible
  endpoint (local models, BYO keys). The client's own auth header passes through;
  the gateway stores no keys.

The redaction engine is the **same code** the ChatPanel extension runs — the
[`@chatpanel/pii`](https://github.com/chatpanel/chatpanel-pii) package is the
single source of truth, so a privacy feature added once is shared everywhere.

## Quick start — the one thing to install

The gateway **carries the [bridge](https://github.com/chatpanel/chatpanel-bridge)**
(0.6.92+): `@chatpanel/bridge` is a dependency, bundled into the same binary, and the
gateway starts it as a child process when nothing already answers on 4319 — or adopts a
bridge that is already running (the desktop app's, or a standalone you installed; a newer
standalone is preferred so bridge fixes keep their own cadence). Two processes on purpose:
the bridge spawns your CLIs and holds SCM tokens; the model runtimes live here. Log into
the CLIs (`claude`, `codex`, …) as you normally would — that is all.

```bash
# Standalone binary — no Node.js required:
curl -fsSL https://dl.chatpanel.net/install.sh | bash   # macOS / Linux
#   Windows (PowerShell):  irm https://dl.chatpanel.net/gateway/install.ps1 | iex

# Or via npm (needs Node.js 22.13+):
npm install -g @chatpanel/gateway --ignore-scripts
chatpanel-gateway --install
# → ChatPanel Privacy Gateway v0.55.0: running at http://127.0.0.1:4320, starts at login.
#     bridge: embedded v0.29.5 — local agents are available.
```

`--install` is the step to run after every install **and every upgrade**: it registers the login
auto-start, stops an older gateway still running from before (npm replaced its files, not the
running process), and waits until the new version — and its bridge — answer. Plain
`chatpanel-gateway` runs it in the foreground instead, for a terminal; if another gateway already
holds 4320 it says which one and exits.

**Upgrading from the standalone binary to npm:** `install.sh` put `~/.local/bin` first on your
PATH, so `chatpanel-gateway` still means the old binary there. Run the npm copy by path once —
`"$(npm prefix -g)/bin/chatpanel-gateway" --install` — and it takes over the login service.

On Windows, if `chatpanel-gateway` is "not recognized" right after `npm i -g`, npm's global bin
folder is not on that shell's PATH yet: open a new PowerShell, or run it by path —
`node "$(npm root -g)/@chatpanel/gateway/bin/chatpanel-gateway.js" --install`. **Updating on
Windows:** stop the running gateway first, or npm fails with `EBUSY` on `onnxruntime_binding.node`
(a loaded DLL cannot be overwritten) —
`chatpanel-gateway --stop; npm i -g @chatpanel/gateway --ignore-scripts; chatpanel-gateway --install`.

**On a company network.** The gateway is on the public npm registry. If `~/.npmrc` points npm at
a company mirror, the install can be slow, a version behind, or refused — name the registry on the
command: `npm i -g @chatpanel/gateway@latest --registry=https://registry.npmjs.org --ignore-scripts`.
If npm answers `UNABLE_TO_VERIFY_LEAF_SIGNATURE` or `SELF_SIGNED_CERT_IN_CHAIN`, a proxy is
re-signing HTTPS with a certificate Node does not know: prefix the command with
`NODE_OPTIONS=--use-system-ca` (Node 22.15+ — it then trusts what this computer trusts), or give
npm the company's certificate (`npm config set cafile /path/to/company-ca.pem`), or use the
standalone binary above. Do not set `strict-ssl=false`. The in-app Update does the first of these
by itself, and when an update fails it says which of these it was and the command to run.

**Whose certificates the gateway accepts.** Once installed, the gateway trusts the certificates
this computer trusts (the keychain, the Windows store, the distribution's bundle) as well as its
runtime's own list — so behind a proxy that re-signs HTTPS it reaches its models, its downloads
and its updates the way the browser on that machine does. Checks are never switched off: a
certificate nobody trusts is refused as before. The trade is the usual one on such a network: the
company's proxy can read what the gateway sends to a cloud model, after redaction has run. To
keep to the runtime's list alone, set `"tls": { "systemCa": false }` in
`~/.chatpanel/gateway.config.json` (or `CHATPANEL_SYSTEM_CA=off`) and restart;
`chatpanel-gateway --audit` says which is in effect. Needs Node 22.19+ for the npm install.

**Why `--ignore-scripts`.** The gateway itself has no install script, and the two in its tree — both
under `@huggingface/transformers`, the native model runtime — are ones it needs nothing from:

| Script | What it does | What skipping it costs |
|---|---|---|
| `onnxruntime-node` | Downloads the **CUDA / TensorRT** execution providers on linux/x64 (225 MB from NuGet). The prebuilt CPU bindings for macOS arm64, Linux x64/arm64 and Windows x64/arm64 are already inside the npm tarball. | Nothing. The gateway asks ORT for `['cpu']` on every engine (`src/ort.js`), so those providers were never loaded. |
| `protobufjs` | Prints a caret-vs-tilde advisory about the range its own parent declared. | Nothing. |

So the flag is not a workaround — it is the install. It also means `npm i -g` never runs code from
114 transitive packages on your machine, which is the posture the rest of this gateway is built for.

**npm 12 and newer blocks unlisted install scripts by default** and prints a four-line warning
naming those two packages. That warning is not a broken install: `onnxruntime-node` still loads and
the native runtime is still the fast one. Passing `--ignore-scripts` says the same thing
deliberately and installs quietly. Do **not** take npm's suggested
`--allow-scripts=onnxruntime-node,protobufjs` — on Linux x64 it starts the 225 MB CUDA download.

`GET /health` says which bridge it runs: `bridge.mode` is `embedded`, `standalone`,
`adopted` or `off`. `CHATPANEL_BRIDGE_MANAGED=off` (or `bridge.managed: false`) turns the
supervision off for a host that runs its own bridge — the desktop app does. A
`bridge.url` that is not on this machine is left alone: nothing is started for a remote
bridge.

> **Size.** A fresh `npm i -g` is ~669 MB on disk (~16 s on a warm connection), and
> `chatpanel-gateway --install` takes it to **~327 MB**, down from 798 MB:
> - `onnxruntime-web` is a **devDependency**, not a dependency (0.39.7) — only the standalone
>   binary's build reads it, to embed the WASM runtime a compiled binary needs because it
>   cannot carry a native addon. −129 MB.
> - `--install` **prunes what this install cannot reach** (0.39.8, 0.39.9): the onnxruntime
>   prebuilds for the other four platforms (`onnxruntime-node` ships one tarball for all five
>   and `dist/binding.js` loads exactly one), and the copy of `onnxruntime-web` npm hoists from
>   `@huggingface/transformers`. −340 MB. A reinstall puts them back and the next `--install`
>   takes them off again.
>
> **That WASM copy is not a fallback.** Hide the native binding and transformers throws at
> import rather than switching backends — it never reaches WASM — and `src/ort.js` only looks
> at the WASM runtime when the compiled binary sets `__CHATPANEL_WASM_PATHS__`, which no npm
> install does. So nothing degrades; there was nothing there to degrade to.
>
> Speed is untouched: what is kept is the *native* runtime, which is the whole reason to
> install via npm rather than take the binary. The prune refuses unless your platform's binding
> is present **and** the addon loads, never touches that one, is idempotent, and does nothing at
> all when run from a checkout. `tests/install-weight.test.js` and `tests/prune-install.test.js`
> guard it.

> **Binary vs. npm — same features, very different local-AI speed.** Both run
> identical redaction/routing. But the standalone binary runs the local models
> (speech-to-text, diarization, NER) on the **WASM** runtime — **fp32-only,
> single-threaded, slow**. The **npm** install uses the **native** runtime with
> **quantized (q8)** models — in our tests **~10× faster** speech-to-text
> (real-time even on larger, more accurate models). If you'll use voice/meeting
> features, install via **npm**. Don't run both — they can shadow each other on
> `PATH`; check `GET /health` → `stt.runtime` (`native` vs `wasm`).

Then point your front-end agent at it. **opencode** (`opencode.json`):

```jsonc
{
  "provider": {
    "chatpanel": {
      "npm": "@ai-sdk/openai-compatible",
      "options": { "baseURL": "http://127.0.0.1:4320/v1" },
      "models": { "codex": {}, "claude": {} }   // selects the agent behind the gateway
    }
  }
}
```

Now opencode talks to codex **through** the gateway — every prompt is redacted
before codex sees it, and the reply is restored before opencode renders it. The
request's `model` (`codex`/`claude`/`opencode`/`pi`) picks which agent the bridge
drives; otherwise the configured default (`codex`) is used.

### Quick start (api backend — no bridge)

If all you want is the gateway as a **redacting proxy in front of an API model**,
you do **not** need the bridge. Set `backend: "api"` and (optionally) point the
gateway's upstream at your provider — in `~/.chatpanel/gateway.config.json`:

```json
{
  "backend": "api",
  "upstreams": {
    "openai":    { "baseUrl": "https://api.openai.com" },
    "anthropic": { "baseUrl": "https://api.anthropic.com" }
  }
}
```

Then point your **client** at the gateway and send your **own** API key — the
gateway redacts, forwards to the provider with your key (it stores none), and
restores the reply:

```bash
# In your CLIENT's environment (NOT the gateway's — see the footgun below):
export OPENAI_BASE_URL=http://127.0.0.1:4320/v1     # OpenAI-compatible: codex / aider / cursor / SDKs
export ANTHROPIC_BASE_URL=http://127.0.0.1:4320     # Claude Code / Anthropic SDK
```

To target a non-OpenAI provider (a local model, OpenRouter, Azure, …) change the
**gateway's** `upstreams.*.baseUrl` in the config above — that's where the gateway
forwards to. Flow: `client → gateway (redact) → provider (your key) → gateway (restore) → client`.

> ⚠️ **Footgun:** `OPENAI_BASE_URL` means two different things — for your *client*
> it's "where the gateway is", for the *gateway* it's "where my upstream is". Don't
> set `OPENAI_BASE_URL=…:4320` in the **gateway's own** environment, or it forwards
> to itself (the loop guard returns 508). Set the gateway's upstream in the config
> file; use the env var only in the client's shell.

## Name/org redaction is built in (in-process NER, no Python)

Deterministic redaction (emails, phones, cards, SSNs, API keys, IPs) needs no
setup. To also blind **names, organizations and locations**, the gateway runs an
**in-process** entity detector — an ONNX transformer model via transformers.js —
with `ner.autostart` on (the default). There's **no Python, no second port, no
separate process**: the same model runs identically on macOS / Windows / Linux.
The model loads from `~/.chatpanel/models` and is downloaded once on first run if
absent (set `ner.allowDownload: false` to require it be pre-placed). It's
fail-open, so if the model can't load the gateway just runs deterministic-only.
Once the detector is ready, redaction switches to the `full` tier automatically.

The default model (`Xenova/bert-base-NER`) matches or beats spaCy's small model on
people/orgs/locations. Larger or alternative models can be installed from the
ChatPanel extension's **Gateway** settings.

Prefer a local LLM or your own external NER service? Set `redaction.detection`
yourself and the gateway won't load the bundled one (yours takes precedence).

## Configuration

Precedence: defaults < `gateway.config.json` (or `$CHATPANEL_GATEWAY_CONFIG`) <
env vars. See [`gateway.config.example.json`](gateway.config.example.json).

| Key | Env | Default | Meaning |
|-----|-----|---------|---------|
| `backend` | — | `bridge` | `bridge` (drive CLI agents via login) or `api` (forward to a provider) |
| `bridge.url` | — | `http://127.0.0.1:4319` | the ChatPanel bridge |
| `bridge.agent` | — | `codex` | default agent the bridge drives |
| `bridge.token` | — | _(auto)_ | bridge bearer token; empty = read `~/.chatpanel/bridge-token` |
| `host` / `port` | `CHATPANEL_GATEWAY_HOST` / `_PORT` | `127.0.0.1` / `4320` | bind address |
| `upstreams.openai.baseUrl` | `OPENAI_BASE_URL` | `https://api.openai.com` | api backend only |
| `upstreams.anthropic.baseUrl` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | api backend only |
| `redaction.tier` | `CHATPANEL_REDACTION_TIER` | `basic` | `basic` (regex) or `full` (+ NER + dictionary) |
| `redaction.detection` | — | _(off → bundled engine)_ | external detector; set to override the bundled in-process one |
| `redaction.dictionary` | — | `[]` | custom `{ value\|pattern, type, alias? }` entries |
| `ner.autostart` | — | `true` | load the bundled in-process NER on startup |
| `ner.model` | — | `Xenova/bert-base-NER` | model id under `~/.chatpanel/models` |
| `ner.allowDownload` | — | `true` | download the model on first run if absent |

`mcp.ask` governs what an MCP client may do with your models — the ceiling for `ask_model`
and the switch for `delegate_task`. See
[Ask another model](#ask-another-model-and-delegate-real-work).

## Endpoints

| Route | Behavior |
|-------|----------|
| `GET /health` | `{ ok, version, backend, tier }` |
| `GET /v1/models` | the agent(s) this gateway exposes |
| `POST /v1/chat/completions` | OpenAI protocol — redact → backend → restore |
| `POST /v1/responses` | OpenAI Responses protocol (Codex) |
| `POST /v1/messages` | Anthropic protocol (Claude Code) |

Streaming (SSE) is supported on all three: placeholders are restored on the fly,
holding back a tail so a token split across chunks (`[[PER` … `SON_1]]`) still
restores cleanly.

### Who may call what

The gateway binds to loopback, refuses any non-loopback `Host` (no DNS rebinding) and any
web `Origin` not in `allowedOrigins` (no drive-by page). Beyond that, callers are told
apart by two honest signals — the browser's `Origin` header and a per-install token —
into four levels (`@chatpanel/pii/trust.js`, the same classifier the bridge applies):

| Caller | How it's recognised | May |
|---|---|---|
| **pinned** | ChatPanel's published Chrome/Edge extension id in `Origin` (+ `allowedExtensionIds`) | everything |
| **token** | `Authorization: Bearer` / `X-ChatPanel-Token` = `~/.chatpanel/gateway-token` (0600) — the desktop, `chatpanel-gateway mcp`, channels, a paired browser | everything |
| **unpaired** | any other extension or a `localhost` page | chat through an *agent* capped to the conversational tier (no files, shell, web, workspace or credentials); API destinations as normal; no admin |
| **local** | no `Origin`, no token — some process | API destinations (the proxy is the product); **not** an agent destination, **not** admin |

An **agent destination spawns a process on your machine**, so it needs a paired caller.
An OpenAI-compatible client sends the token as its API key
(`OPENAI_API_KEY=$(cat ~/.chatpanel/gateway-token)`); the refusal (`401
agent_lane_token_required`) says so. `bridge.openAgentLane: true` restores the pre-0.9.0
behaviour for a trusted machine — the startup log calls it out.

**Pairing** a browser the gateway does not recognise (ChatPanel's Firefox build, whose
origin is a per-profile UUID; a dev build): `chatpanel-gateway pair` prints a one-time
code (5 minutes, 5 attempts); enter it in ChatPanel → Settings → Coding Agents. The exchange
(`POST /pair`) hands over the gateway token and, when this machine has one, the bridge
token — one code covers both. `POST /whoami` → `{ trust, paired }` tells a client where
it stands.

## Use it as an MCP server (Codex, Claude Code, any MCP client)

Add one MCP server and any agent — Copilot in VS Code, Cursor, Claude Code, Codex —
gets **this machine** as tools: your local history, the skills installed here, the small
models that answer in milliseconds, the privacy detector, and the coding agents you are
already signed in to. Work that belongs on your machine stops having to be pasted into a
cloud agent's context.

It is a stdio MCP server: `chatpanel-gateway mcp`. History, memory and capabilities are
served by the gateway; skills and coding agents are proxied from the
[bridge](https://github.com/chatpanel/chatpanel-bridge) (optional — without it the rest
still works and those tools say so).

**Codex** — add to `~/.codex/config.toml`:

```toml
[mcp_servers.chatpanel]
command = "chatpanel-gateway"
args = ["mcp"]
```

> **Corporate Codex with an approval guardian?** If a tool call is rejected with
> *"Automatic approval review failed"* (an `approvals_reviewer = "auto_review"` policy
> can't assess a tool it doesn't know), pre-approve ChatPanel's read-only tools the same
> way you would any other server — one block per tool:
>
> ```toml
> [mcp_servers.chatpanel.tools.search_history]
> approval_mode = "approve"
> [mcp_servers.chatpanel.tools.get_record]
> approval_mode = "approve"
> [mcp_servers.chatpanel.tools.list_history]
> approval_mode = "approve"
> [mcp_servers.chatpanel.tools.list_skills]
> approval_mode = "approve"
> [mcp_servers.chatpanel.tools.open_skill]
> approval_mode = "approve"
> [mcp_servers.chatpanel.tools.read_skill_file]
> approval_mode = "approve"
> ```
>
> All six only read local data (your redacted history and your installed skills), so
> approving them carries no write or network risk.

**Claude Code** — one command (`--scope user` makes it available in every project):

```bash
claude mcp add --scope user chatpanel chatpanel-gateway mcp
```

**VS Code (GitHub Copilot)** — `.mcp.json` in the project, or `mcp.json` in your user
profile for every project:

```json
{
  "servers": {
    "chatpanel": { "type": "stdio", "command": "chatpanel-gateway", "args": ["mcp"] }
  }
}
```

**Cursor** — the same shape in `~/.cursor/mcp.json` (or `.cursor/mcp.json` per project),
under `mcpServers` rather than `servers`.

**Any other MCP client** — run the stdio server `chatpanel-gateway mcp`, or point at it the
way your client configures a `command` + `args` stdio server.

> If your client launches with a stripped `PATH` and cannot find `chatpanel-gateway`, use
> the absolute path (find it with `which chatpanel-gateway`).

> **If a tool is missing, you are probably running two ChatPanels.** Your client launches
> whichever `chatpanel-gateway` is on its `PATH`, and an older one starts a server that simply
> has fewer tools — no error, nothing in a log. The server now detects this itself: it reports
> its real build as `serverInfo.version` (your client's MCP panel shows it) and, when the
> gateway running on this machine is a different version, says so in its instructions so the
> agent can tell you. To fix it, point the MCP config at the newer executable — `which
> chatpanel-gateway` against `curl -s 127.0.0.1:4320/health` — or update the older install.

### Tools it exposes

**Your history and memory** — what you said, and what ChatPanel knows about you.

| Tool | What it does |
|------|--------------|
| `smart_search` | Best first stop: asks your question several ways at once and fuses the rankings |
| `search_history` | One exact keyword query, with `type` / `since` / `before` filters |
| `get_record` | One record's full text by id (`chat:…`, `meeting:…`, `note:…`), pageable |
| `find_related` | Given a record, the ones most connected to it |
| `list_history` | Browse/page the corpus (newest first, no bodies) |
| `list_briefs` / `get_brief` | The maintained page about a person, project or topic — every claim citing its record |
| `recall` | The durable facts about you, so an agent follows them without being told |
| `remember` / `forget` | Save or drop one such fact. Writes are stamped with which agent made them |

**Skills** — every skill installed on this machine, across Claude Code, Codex, Copilot,
Gemini, Hermes, `~/.agents/skills` and any folder you configure.

| Tool | What it does |
|------|--------------|
| `list_skills` | Name + one-line description, and where each came from |
| `open_skill` | One skill's full instructions |
| `read_skill_file` | A reference file those instructions point at |

**This machine's capabilities** — things your box can do that a remote agent cannot.

| Tool | What it does |
|------|--------------|
| `redact_check` | What is personal or secret in a piece of text, found by the detector here. Call it **before** pasting someone's data anywhere |
| `read_document` | A URL as clean markdown through your reader and cache — a fraction of the context raw HTML costs |
| `decide` | A sentiment / urgency / yes-no / pick-one judgement with a probability per option, in milliseconds |
| `rerank` | Order many candidates by relevance, best first, instead of reading them all |
| `web_search` | Your own search (your SearXNG when one runs here), top pages already read |

**Your models** — see [Ask another model](#ask-another-model-and-delegate-real-work) below.

| Tool | What it does |
|------|--------------|
| `list_models` | What can answer, grouped by **where each one runs** |
| `ask_model` | Hand a question to one and get the answer. The model answers from your prompt alone |
| `delegate_task` | Hand a whole piece of work to a coding agent here, in a directory you allow. **Off by default** |
| `ask_status` | Collect the result of a slow `ask_model` or `delegate_task` |

Everything a history tool returns is **redacted** with the same engine
([`@chatpanel/pii`](https://github.com/chatpanel/chatpanel-pii)) the gateway uses for model
traffic — the real values never leave your device. Skill scripts are never served as text.

### Ask another model, and delegate real work

`ask_model` hands a question to any model this machine can reach — a coding agent you are
signed in to, a local engine, an API key you configured — and returns the answer as text.

```
ask_model(prompt: "Does this migration lose data? <paste>", model: "claude")
→ …the answer…
  — answered by claude · a cloud provider — this text left the machine (redacted first) · 2 values redacted before it was sent · 6.4s
```

Every answer says **which model produced it, whether the text left this machine, and what
redaction replaced on the way out**. You asked a server on localhost; that line is the only
way to know the question went to a cloud. `list_models` groups by the same fact, so an
agent can keep sensitive text on the box by picking a `device` model.

The model you ask is **blind**: no filesystem, no shell, no web, no tools. It answers from
the prompt and nothing else, so an `ask_model` call cannot read anything the caller could
not already read. That is the default and it is not the caller's to change — see
[What the caller may and may not do](#what-the-caller-may-and-may-not-do).

`delegate_task` is the other half: one of your coding agents running **here, with its real
tools, in a real directory** — the refactor a remote agent would need forty file reads to
attempt, the test suite it cannot run, the build it cannot see.

```
delegate_task(task: "The tests in src/auth fail since the token change. Fix them.",
              workingDir: "~/code/app")
→ claude has started work in /Users/you/code/app.
  Call ask_status with job id "ask_1" in 15-30 seconds…
```

It takes minutes, so it returns a job id you collect with `ask_status`; pass `wait: 0` to
get the id immediately and carry on with your own work.

**`delegate_task` does not exist until you switch it on.** With no directories configured
it is not listed, not callable and not mentioned to the model — a default install has no
delegation surface at all. To enable it, name the directories an agent may work in:

```jsonc
// ~/.chatpanel/gateway.config.json
{
  "mcp": {
    "ask": {
      "maxReach": "device",              // the ceiling for ask_model (below)
      "defaultModel": "",                // answers a call that names no model
      "delegate": {
        "roots": ["/Users/you/code/app"],   // the on-switch AND the boundary
        "permissionMode": "acceptEdits"     // what a delegated run may do
      }
    }
  }
}
```

`permissionMode` is `acceptEdits` by default: a delegated agent may **edit files in that
directory**, and the shell still asks — which is denied, because nobody is there to answer.
`"default"` makes it read-only (good for "survey this codebase"); `"bypassPermissions"`
also gives it the shell. Restart the gateway after editing this file.

A path outside every root is refused with the list of what is allowed, and containment is
checked on the resolved path — `/srv/app-secrets` is not inside `/srv/app`, and `..` cannot
climb out.

### What the caller may and may not do

The agent calling these tools reads web pages and repo files for a living, so it is treated
as capable of being prompt-injected. Three things hold regardless of what it asks for:

1. **An asked model is conversational only.** `reach: device` means no filesystem, shell,
   web, writes or subagent — enforced where the process is spawned, not by asking it nicely.
2. **The caller cannot raise its own ceiling.** `reach` is a tool argument, so a model will
   eventually ask for more; it gets the stricter of what it asked and your `mcp.ask.maxReach`.
   An unrecognised value caps to `device`. Privilege comes from your config file, never from
   the conversation.
3. **No tool here opens a local file by path.** `read_document` takes a URL only. The one
   exception is `delegate_task`, which is off until you name its directories.

Raising `maxReach` to `"any"` lets `ask_model` run an agent at its configured permission
mode. Only Claude Code turns a reach tier into a tool policy, so the other agents are
*refused* under a capped tier rather than quietly running uncapped — the refusal says so.

Every tool call is attributed to the client that made it (`clientInfo` from the MCP
handshake) in the gateway's access log, so ChatPanel can tell you which agent read what.

### One local view

```bash
chatpanel-gateway local
```

prints what is running — the gateway (this) and the bridge (your agents + skills) — so you
can see the whole local runtime at a glance. The bridge is an optional companion; a missing
one is reported plainly, never as an error.

### Letting a partner server in (ChatPanel Link)

```bash
chatpanel-gateway link pair --partner acme [--scopes models,chat[,agents]] [--route link|relay|tailscale|cloudflare] [--relay <url>] [--yes]
chatpanel-gateway link                 # every paired device: kind, partner, scopes, route, last seen
chatpanel-gateway link revoke <id>     # removed at once — its key, its room, its connection
chatpanel-gateway link route <mode> [--relay <url>] [--gw <tunnel url>] [--no-fallback]
```

A service you use can reach this gateway's models from its own server through ChatPanel Link —
end-to-end encrypted, no port opened — but only after you pair it here. `link pair` shows what
the partner will be able to do (its scopes, whether it may reach your coding agents — off unless
you add `agents` — the route and the one host it connects to) and asks before it prints a
one-time `cplink1.` code (10 minutes, one use). The route is the gateway's own unless `--route`
names another: a Tailscale or Cloudflare Tunnel route reaches this computer's tunnel door with no
relay at all. A partner reaches only `GET /v1/models` and chat with API models — never pairing,
settings, history or memory. Granted `agents` (`--scopes models,chat,agents`), its coding-agent turns
run as yours do: your Coding Agents settings, the sandbox and your organisation's policy decide what
they may do, in a folder of its own (`--folder`, default `~/.chatpanel/partners/<name>`) where its
skills (`.claude/skills`), subagents (`.claude/agents`) and instructions live. The partner chooses
none of that, and never answers an agent's approval prompt: when one of its agents asks to do
something your permission mode does not already allow, the request waits for YOU — Settings › Link,
or `chatpanel-gateway link approvals` then `link approve <id> [--for action|all]` / `link deny <id>`
— and you allow it once, allow that action for the rest of the conversation, allow everything in it,
or say no (no answer in 10 minutes is a no). The partner's streamed response stays open meanwhile.
Granted `files` too (`--scopes models,chat,agents,files`), the partner may put its data (`data/`),
skills, subagents and instructions in that folder and read back what its agents wrote there
(`GET/PUT/DELETE /v1/link/files/<path>`) — never `.claude/settings*.json`, hooks, `.mcp.json` or
`.codex/`, never a skill or subagent whose front matter would widen what the agent may do, and never
through a link out of the folder.
Partners show
in `--audit` and in Settings › Link › Servers, with Revoke — where a server can also be connected without a terminal.

### What leaves your machine — and how to check

```bash
chatpanel-gateway --audit          # or --audit --json
```

prints two lists. **Observed:** every host this gateway process has actually contacted
since it started — model upstreams, the entitlement check, model-weight downloads, the
update check — by hostname and port with a count and first/last seen (no paths, headers or
bodies are kept). **Declared:** every host the current configuration *allows*, each with
the reason, when it happens, what is sent, and the setting that turns it off. The same
data is `GET /audit` (admin-authenticated, like `/config`), which the extension's Gateway
tab shows.

The audit covers this process. It does not see the embedded bridge (a separate process)
or the CLI agents it runs — Claude Code, Codex and friends reach their own vendors with
your own logins — nor a client's calls that bypass the gateway. Any host that shows up in
your firewall or proxy log for this process and is not on the declared list is a bug:
security@chatpanel.net.

## How it fits with ChatPanel

The extension redacts inside the browser; the bridge lets the
browser drive local CLI agents. This gateway reuses the bridge to put the **same
redaction engine** ([`@chatpanel/pii`](https://github.com/chatpanel/chatpanel-pii))
in front of *any* agent — so non-browser tools get the privacy too, and the
agent's own multi-turn loop is blinded, not just the first prompt.

## Caveats

- **Reversibility** is best-effort: if the model paraphrases a placeholder instead
  of echoing it, that one reference shows the token. The privacy guarantee (the
  real value never left the device) always holds.
- A dictionary **alias** is a *permanent* pseudonym — the agent sees the alias,
  not the original, by design.
- **Code edits**: redacting values that appear inside source can affect round-trip
  edits. The default tier touches only structured secrets and (in `full`) detected
  entities — keep your dictionary prose-focused.

**Using it as an MCP server:**

- The **gateway must be running** for any of its tools to work — it is a background
  service (`chatpanel-gateway --install` registers it to start at login). If a tool
  returns *"the ChatPanel gateway is not running"*, start it and retry; the message tells
  you the command.
- **Skills need the bridge** (an optional companion). Without it, the history tools still
  work and the skill tools return a one-line *"the bridge is not running — install it
  with …"* — no silent empty result.
- History tools search the gateway's **warm store**, which is seeded from your ChatPanel
  backups. If `list_history` says it is empty, the gateway has not been seeded yet — open
  ChatPanel so a backup lands, or check the [ingest docs](#endpoints).
- Everything returned is **redacted** at the configured tier. A tool result may contain a
  placeholder like `[[EMAIL_1]]` where a value was blinded — that is the privacy guarantee
  working, not a bug.
- **`redact_check` tells you what it could not see.** With the entity detector off or the
  tier at `basic`, patterns (emails, phones, cards, keys) are still checked but names and
  organisations are not — and a text full of names then looks exactly like a clean one. The
  result carries a `CAUTION` line whenever that is the case. Read it: "no findings" under a
  patterns-only pass means *no patterns found*, not *nothing personal here*.
- **`ask_model` and `delegate_task` need a model to reach.** `ask_model` with no coding
  agent signed in, no local engine and no API destination has nothing to ask — run
  `list_models` first, and it says so plainly when the list is empty.
- **A slow answer is normal, not a hang.** A coding agent on a real question takes minutes.
  Past `wait` seconds you get a job id instead of a failure; poll `ask_status` every 15–30
  seconds. The work continues whether or not you poll, and the job id lives as long as the
  MCP session — a client that reconnects gets a fresh server and the id is gone.
- **`delegate_task` is absent unless you configured `mcp.ask.delegate.roots`** and restarted
  the gateway. If you expected it and do not see it, that is why.

## License

[PolyForm Shield 1.0.0](LICENSE) — you may run and modify it for your own use, not to
build a competing product. The npm package and the binaries ship minified; what the
gateway does with your data is documented above and verifiable with `--audit`.
