# 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):
npm install -g @chatpanel/gateway
chatpanel-gateway
# → ChatPanel Privacy Gateway on http://127.0.0.1:4320
#     backend  : bridge (agent: codex, via http://127.0.0.1:4319)
#     bridge   : starting the embedded bridge (v0.11.20)
```

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; chatpanel-gateway --install`. npm may also warn
that `boolean@3.2.0` is deprecated and that `onnxruntime-node` / `sharp` / `protobufjs` run install
scripts: all three come through `@huggingface/transformers` (the native model runtime) and are
expected.

`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.

> **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 |

## 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 → Agent Tools. 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)

Point one MCP server at the gateway and any CLI agent can reach your **local history**
(past chats, meeting transcripts, notes — redacted on the way out) **and every skill
installed on your machine** — across Claude Code, Codex, Copilot, Gemini, Hermes,
`~/.agents/skills` and any folder you configure. History is served by the gateway; skills
are proxied from the [bridge](https://github.com/chatpanel/chatpanel-bridge) (optional — if
it is not running, the history tools still work and the skill tools say so).

It is a stdio MCP server: `chatpanel-gateway mcp`.

**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
```

**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`).

### Tools it exposes

| Tool | What it does |
|------|--------------|
| `search_history` | Full-text search your chats, meetings and notes by relevance |
| `get_record` | Fetch one record's full text by id (`chat:…`, `meeting:…`, `note:…`) |
| `list_history` | Browse/page the corpus (newest first, no bodies) |
| `list_skills` | List every installed skill (name + one-line description, and where it came from) |
| `open_skill` | Load one skill's full instructions by name |
| `read_skill_file` | Read a reference file a skill's instructions point at |

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.

### 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.

### 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.

## 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`.
