# penname

Your app talks to a model provider. penname sits in between, swaps every
customer name, email and phone number for a pen name on the way out, and puts
the real values back in the reply.

```
your app  ->  penname gateway  ->  OpenAI / Anthropic / any OpenAI-compatible
              (your machine)       sees PERSON_7K3Q2M9XAB, never "Dana Levy"
```

Everything has to go through it. A call that reaches the provider directly is
not protected, and nothing anywhere will tell you so.

Nothing about your application changes except one URL. Setup takes about ten
minutes.

---

## The fastest way: sign up, then let your agent do it

1. **Get a key** — open **https://penname.io/signup**, create an
   account (no payment), and copy the `pk_...` key it shows you.
2. **Paste this to your coding agent** — Claude Code, Codex, Cursor, or the
   Claude / ChatGPT desktop app. Put your key where it says `pk_...`:

   ```text
   I want to add penname to this project. penname pseudonymizes customer data
   (names, emails, phones, ID numbers) before my code calls OpenAI or Anthropic,
   and restores the real values in the replies. My penname key is pk_...  Please:

   1. In a new "penname" folder, set up and start the gateway:
        docker run -it -v ${PWD}/penname:/setup pennameio/gateway init
      (paste my key when asked; for the database URL use our app's DB with a
      READ-ONLY user and host.docker.internal instead of localhost) then
        cd penname && docker compose up -d
   2. Add penname's MCP so you can read our schema and configure it:
      - Claude Code: claude mcp add --transport http penname \
          https://penname.io/mcp \
          --header "Authorization: Bearer pk_..."
      - Cursor / Codex / other: add an MCP server with that URL and header.
   3. Call the MCP tool penname_setup_checklist and follow it: point every
      OpenAI/Anthropic client at the gateway, keep our provider key, add the
      X-Penname-Scope header, and mark our sensitive columns with
      penname_mark_columns. Use penname_troubleshoot if anything is off.
   4. Before any real customer data goes through, PROVE it with test data:
      call penname_verify and follow it. A fake test customer,
      DETAIL_LOGGING=true in penname/.env, every AI feature run once, then
      the gateway log read: only pen names in what was sent, and no trace of
      the test customer's name or email. Show me that proof.
   5. Then turn DETAIL_LOGGING off again: DETAIL_LOGGING=false in
      penname/.env, then docker compose up -d --force-recreate. Left on, it
      prints every prompt in full and fills the log. Check that
      docker compose logs gateway no longer says "words sent".

   Do not tell me it is done, and do not send real customer data to the
   model, until that test has passed and DETAIL_LOGGING is off again.
   ```

The signup page hands you this same prompt with your key already filled in. The
rest of this README is the manual version of exactly what that prompt does.

---

## Before you start

| You need | Where it comes from |
| --- | --- |
| Docker Desktop, running | [docker.com](https://www.docker.com/products/docker-desktop/) |
| Your penname API key, `pk_...` | we issue it — step 1 |
| A read-only database URL | your own database |

Docker is the only thing you install. No Python, no packages.

---

## What penname can see

penname is two separate things, and the difference is the whole product:

```
your database  --rows-->  the gateway  --aliases only-->  OpenAI
                          (your machine)
                                |
                                | table and column names. never a row.
                                v
                          the dashboard (hosted)
```

**The gateway runs on your machine and does read your data.** It has to: the
only way to know that `Basel Haddad` should become `PERSON_7K3Q2M9XAB` is to
have read the name. It runs `SELECT` against the tables you point it at, and as
a proxy it also holds the prompt in memory before substituting. It sends
nothing anywhere except the protected payload, to the model provider you
already use.

**The hosted dashboard never receives any of that.** It gets table names,
column names and types, the relationships between them, which columns you
marked sensitive, and token counts for billing. No rows, no names, no prompts,
and never your master key.

So the accurate sentence is: *penname the company cannot see your data. The
penname process running inside your own network can, and that is the one doing
the work.*

If you want to check rather than take it on faith, watch what the gateway
sends: `DETAIL_LOGGING=true` prints every word sent to the model, and the only
other destination is the control plane, which gets your key, your table and
column names, and token counts. Your key is authorized there before each call — that
is the one thing the gateway will not run without, and it carries no data.

### If you want it to never read a row

```
PENNAME_SCHEMA_ONLY=true
```

The gateway then reads your column names and never runs a `SELECT` for data. It
says so at startup, every time:

```
NOTE  PENNAME_SCHEMA_ONLY is on: this gateway reads your column names but
      never your rows. Emails, phones and ID numbers are still protected --
      they have a shape. NAMES ARE NOT.
```

That last part is the whole trade, and it is not a limitation we can engineer
away: **a name cannot be replaced by something that has never read it.** An
email looks like an email, so a detector finds it in the prompt with no
database at all. `באסל חדאד` looks like ordinary text — the only way to know it
is a customer's name is to have seen it in your customer table.

So choose deliberately: full protection with the gateway reading rows **inside
your own network**, or shaped values only with it never touching your data.
Most people want the first, having seen that the hosted side receives neither.

---

### Images go through untouched

penname substitutes **text**. It does not read pixels. A scanned contract, a
screenshot, a photo of an ID card — these reach the model exactly as you sent
them, with every name and number still on them.

For a documents product that is the most sensitive payload there is, so the
gateway now says so: the first call carrying an image logs a warning, every
such call is marked `IMG(n unread)` in the log, and the count reaches your
dashboard. Nothing is blocked — penname cannot tell a signature from a logo —
the decision is yours:

- keep images out of prompts that must be protected, or
- extract the text first (OCR) and let penname see *that*, or
- accept it, knowing which calls carried one.

---

## The two keys

This is the one part people mix up, so before anything else:

| | `PENNAME_MASTER_KEY` | `PENNAME_API_KEY` |
| --- | --- | --- |
| What it does | pen names are derived from it | licenses this gateway and signs you in to the dashboard |
| Required? | yes — the gateway will not start without it | yes — same |
| Who creates it | **you**, once | penname |
| Looks like | 64 hex characters | `pk_...` |
| Leaves your machine? | **never** | yes, that is its job |
| If you lose it | every alias ever issued becomes unresolvable | we issue a new one |

Neither of them is your OpenAI key. penname never sees that one — your app keeps
sending it exactly as it does today and the gateway passes it straight through.

---

## Building with Lovable, Base44, Cursor or Claude Code?

Most of the setup is something an agent can do. Paste this to it:

```text
We are putting the penname gateway in front of our model calls. It is an
OpenAI-compatible proxy that runs on localhost:8088 and pseudonymizes customer
data before it reaches the provider. Work through these in order.

A. FIRST, tell me what can be protected. Change nothing yet.
   Read our database models / migrations. For every table, report whether
   anything says which user, account, tenant or record owns the row - a foreign
   key, or a column like owner_id, user_id, tenant_id.
   List the tables where nothing does. Rows in those cannot be tied to anyone,
   so names in them will reach the model in the clear (they can still be marked
   "not sensitive" or "secret" in the dashboard - those work regardless).
   I need to know that before we start, not after.

B. Set the gateway up with its own wizard, in a new folder (e.g. ./penname):
       docker run -it -v ${PWD}:/setup pennameio/gateway init
   It asks for our pk_ API key first - STOP there and let me paste it; you
   never see it. Then it asks for a database URL: give it our application's
   own URL changed in two ways - a READ-ONLY database user, and
   host.docker.internal in place of localhost or 127.0.0.1 (inside a
   container localhost means the container). An async driver (+asyncpg,
   +aiomysql) is fine, the gateway swaps it itself.
   It generates the master key itself and prints it once. TELL ME that key so
   I can store it: if it is lost, every alias we have ever issued becomes
   unresolvable. It writes .env, secrets.env, docker-compose.yml and a
   .gitignore. Do not edit them, do not invent values, and never paste
   secrets.env anywhere.

C. Find EVERY place we construct a model client - OpenAI, Anthropic, or any
   other. Search the whole codebase: background jobs, title generators,
   summarisers, evals, one-off scripts. There are always more than one, and
   every one you miss sends real customer data to the provider with nothing
   protecting it and no error to show for it. List them all before changing
   any.

D. Point each of them at the gateway instead of the provider:
       OpenAI-compatible:  base_url = "http://localhost:8088/v1"
       Anthropic/Claude:   base_url = "http://localhost:8088"
   One gateway serves both - it speaks /v1/chat/completions and /v1/messages.
   Read it from an env var PENNAME_GATEWAY_URL, falling back to the provider's
   normal URL when the var is empty, so we can turn this off without a deploy.
   If our app itself runs inside Docker, use http://host.docker.internal:8088
   as the value instead.
   If our code chooses a provider at runtime, make sure BOTH branches honour
   that base URL. A branch that ignores it is a silent leak.

E. Leave the provider API key handling exactly as it is. The gateway forwards
   our key untouched. Do not move it, rename it, or put it in the URL.

F. On every one of those clients, add a default header saying which records the
   request is about. Do NOT guess the key names. Start the gateway, then run:
       curl -s localhost:8088/scope
   It lists the exact keys, worked out from our own schema, with an "example"
   line. Send every one of those ids we have in hand at that point:
       X-Penname-Scope: tenant=45; business=89; engagement=E-2026-17; user=123
   Whatever we cannot supply, leave out - the gateway follows our foreign keys
   and reaches those tables from an outer id. In the OpenAI SDKs this is
   `default_headers` (Python) or `defaultHeaders` (JS). The header is harmless
   when the base URL points straight at the provider - an unknown header is
   ignored - so it is safe to add everywhere.

G. Change nothing else. No new dependency, no change to prompts, no change to
   how responses are parsed. The gateway returns ordinary responses with the
   real values already put back.

H. PROVE IT WITH TEST DATA before any real customer goes through:
   - Create a clearly FAKE customer through our own app, e.g. "Zelda
     Testington", zelda.testington@example.com.
   - Add DETAIL_LOGGING=true to the gateway's .env, then
     `docker compose up -d --force-recreate`.
   - Run EVERY AI feature from step C once, about that customer, with the
     scope header set.
   - Read `docker compose logs gateway`. Every feature must have left a line
     starting "->" (one that did not bypassed the gateway). No line may say
     NO-SCOPE or IMG. In every "sent to the model, word for word" block, each
     name, email and phone must be a pen name (PERSON_..., EMAIL_...).
   - Search the log for the test values:
     `docker compose logs gateway | grep -i -e Testington -e zelda.testington`
     must print NOTHING. Anything it prints left in the clear: fix it, retest.
   - Then TURN IT OFF: DETAIL_LOGGING=false and force-recreate again. Left on,
     it prints every prompt in full and fills the log. Confirm
     `docker compose logs gateway | grep "words sent"` prints nothing.

I. Do NOT tell me this is finished until you have given me the handover below.
   "The code changes are done" is not the same as "it is running", and I have
   no way to see which of the two you mean.

HANDOVER - end your reply with exactly these four things:

   1. WHAT I MUST STILL PROVIDE. Go through the .env line by line and list any
      value that is empty or a placeholder, what it is, and where I get it.
      Say plainly if the answer is "nothing, it is complete".

   2. THE COMMANDS TO RUN, in order, copy-pasteable, starting from the
      directory I should be in.

   3. THE PROOF FROM STEP H. Which features you tested, one "sent to the
      model, word for word" block from the log, and the output of the search
      (which must be empty), and confirmation that DETAIL_LOGGING is off
      again. No proof, not finished.

   4. WHAT IS NOT PROTECTED. The tables with no owner column from step A, and
      any model call you could not get a user id into (background jobs, title
      generation, cron). Do not invent a user id and do not skip the change -
      list them, because those calls stay partially protected until we thread
      one through.

   Also tell me which files you changed and the master key you generated.
```

Two things are still yours, because they are not code. **You** paste in the
`pk_...` API key (step 1 below), and **you** decide which columns are sensitive
in the dashboard (step 4). Nobody else can make either call.

### Let your coding agent read and set the field map (MCP)

penname runs an **MCP server**, so the agent doing the wiring above can also see
your schema and change what is sensitive — without you switching to the
dashboard. It is hosted; there is nothing to install. Add it once, with your
`pk_` key:

```bash
claude mcp add --transport http penname \
  https://penname.io/mcp \
  --header "Authorization: Bearer pk_your_key"
```

Cursor and other clients take the same URL and header in their MCP config. The
agent then has these tools, all scoped to your key and your account only:

| Tool | What it does |
| --- | --- |
| `penname_setup_checklist` | the exact code changes to make, tailored to your account — read this first |
| `penname_schema` | the tables and columns penname holds (names only, never a row) |
| `penname_field_map` | what is currently marked sensitive, and your keywords |
| `penname_mark_column` | mark a column PERSON / EMAIL / SECRET / … or **not sensitive** |
| `penname_add_keyword` / `penname_remove_keyword` | protect a name that lives only in prose, or exempt one that isn't sensitive |
| `penname_usage` | calls, tokens and what was protected — counts only |

It reads the schema your gateway uploaded and writes through the same validated
store the dashboard uses, so a change shows up in a running gateway within about
ten seconds. It cannot see a row, and one key can never touch another account.

---

## Step 1 — Get your API key

Sign up at **https://penname.io/signup**. No payment needed. You get a key
starting with `pk_`. **Copy it straight away: it is shown once and never
again.**

---

## Step 2 — Run the setup wizard

In an empty folder that will hold penname's files — the same command on
macOS, Linux and Windows PowerShell:

```bash
docker run -it -v ${PWD}:/setup pennameio/gateway init
```

It asks for two things, and checks the first **before writing anything**:

1. **Your `pk_` key.** A key the control plane refuses writes nothing, so a
   dead key cannot leave you with a setup that looks finished.
2. **Your database URL**, for a **read-only** user — or Enter to skip for now.
   `localhost` is rewritten to `host.docker.internal` for you: inside a
   container, `localhost` means the container.

Then it generates your master key, prints it **once**, and writes four files:

| File | Holds | Share it? |
| --- | --- | --- |
| `.env` | settings: port, provider, control plane URL | yes — safe for a support ticket |
| `secrets.env` | master key, `pk_` key, database URL | **never** |
| `docker-compose.yml` | pulls the image; nothing to edit | — |
| `.gitignore` | keeps `secrets.env` and `.env` out of git | — |

Store the master key wherever you keep database passwords. Nobody can re-issue
it. A second gateway for the same data — staging, a replica — must be given
the **same** key, or the same person gets a different pen name from each:
`penname-gateway key show` prints it from an existing install, and
`init --master-key <key>` hands it to the next one.

Editing `secrets.env` by hand? **No quotes, no spaces around `=`.**
`KEY=value`, nothing else.

**Granting SELECT on only some tables is fine and encouraged.** The gateway
checks what it may read when it starts, protects those tables, and says plainly
which ones it was refused:

```
2 table(s) this database user may not SELECT, so names in them are NOT
protected: invoices, ledger_entries. Grant SELECT on them, or accept that
they are unprotected.
```

Nothing breaks; you just get exactly the coverage you granted. Start with the
tables holding people's names and widen later.

You never edit `docker-compose.yml`. Settings live in `.env`, secrets in
`secrets.env`, and nothing else is yours to touch.

---

## Step 3 — Start the gateway

```bash
docker compose up -d
docker compose logs -f
```

You are looking for one line, printed once at startup:

```
penname gateway up: GatewayConfig(upstream='https://api.openai.com/v1',
  customer=None, db=set, tables=6, ner=False, control_plane=set) (tables=6)
```

Two things in it tell you whether you are set up:

- **`db=set`** — it read `PENNAME_DB_URL`. `db=none` means the variable never
  arrived; check the spelling in `.env` and recreate the container.
- **`(tables=6)`** at the end — how many tables it can tie to a person. Zero
  means it could not reach your database, or no table has both an id and an
  owner column.

No secret is ever printed on this line.

Just above it, the gateway says how it worked your schema out:

```
owner table: users
36 tables scoped (user x23, tenant x8, document x1, ...)
30 table(s) not scopable, names in them are not protected: announcements, ...
scope keys (outermost first): tenant, business, engagement, user
```

It reads the ownership chain off your foreign keys, so it works whether you call
them `users`, `accounts`, `tenants` or `members`, and whether the link is
`owner_id`, `user_id` or `tenant_id`. **Read the third line** — it is the honest
list of what is not covered. The fourth is what to put in the scope header
(step 6).

---

## Step 4 — Say what is sensitive

The gateway has just told the dashboard your **table and column names** — never
a single row. Now you decide what each column holds.

1. Open `https://penname.io/app`
2. Paste your `pk_...` key, press **Sign in**
3. Press **Auto-suggest** — a starting point, not an answer
4. Correct it. Each column is `PERSON`, `EMAIL`, `PHONE`, `NATIONAL_ID`,
   `SECRET`, or **not sensitive**
5. Press **Save**

Your changes reach the gateway within about ten seconds. **No restart.**

Marking a column *not sensitive* is respected even when the detectors would have
caught it — your decision wins.

**Check the numeric columns before you save.** Auto-suggest reads column
*names*, so anything called `max_email_messages`, `email_count` or
`signer_index` gets proposed as EMAIL or PERSON even though it holds a number.
Left in, a limit of `10` becomes an alias and then *every* `10` in the prompt is
swapped for it. Sort by type, set the integer columns to **not sensitive**, and
the problem is gone for good. Real names, emails and phones are the only things
that belong in this map.

---

## Step 5 — Point your app at the gateway

Change the base URL your model client already uses.

**OpenAI, or anything OpenAI-compatible:**

```python
client = AsyncOpenAI(
    base_url="http://localhost:8088/v1",     # was https://api.openai.com/v1
    api_key=your_openai_key,                 # unchanged
)
```

**Anthropic / Claude:**

```python
client = AsyncAnthropic(
    base_url="http://localhost:8088",        # was https://api.anthropic.com
    api_key=your_anthropic_key,              # unchanged
)
```

That is the whole code change. Keep sending your provider key — the gateway
forwards it and never stores it. One gateway serves both: it speaks
`/v1/chat/completions` and `/v1/messages`, so an app that switches providers
changes its own client and nothing else.

### Every call, or it does not count

> **A call that does not go through the gateway is not protected — and nothing
> will tell you.** No error, no warning, no gap in a chart. The prompt simply
> arrives at the provider with your customers' real names in it.

This is the one way to hold this wrong, and it is easy to hold wrong, so it is
worth being blunt about:

- **Every provider.** GPT and Claude both, and any other you add. We shipped
  the OpenAI route first, and for a while an app that switched to Claude went
  straight to Anthropic with no protection and no complaint. Do not repeat it.
- **Every client.** Search the whole codebase, not just the obvious file.
  Background jobs, title generators, summarisers, evals, that one script —
  each constructs its own client, and each one you miss is a hole.
- **Every environment.** A staging box pointed at the provider leaks the same
  data as production would.

Two ways to check you have them all:

```bash
# 1. Anything still naming a provider directly is a call that bypasses you.
grep -rn "api.openai.com\|api.anthropic.com" your-app/

# 2. The gateway counts what it sees. If your app made ten calls and the
#    dashboard shows three, seven went somewhere else.
```

The **Usage** page in the dashboard is the ongoing version of that second
check: a live gateway, a call count that matches what your app did, and a
model breakdown naming the models you actually use.

---

## Step 6 — Tell it whose data this is

Add one header, carrying the ids the request is about:

```python
client = AsyncOpenAI(
    base_url="http://localhost:8088/v1",
    api_key=your_openai_key,
    default_headers={"X-Penname-Scope": f"user={user.id}"},
)
```

**Do not skip this one.** Emails, phones and ID numbers have a shape a detector
can recognise, so they are protected either way. **Names do not.** The header is
how the gateway knows which rows to load, and without it names go to the model
in the clear.

It is harmless when the URL points straight at OpenAI — an unknown header is
ignored — so you can ship it before you switch the URL over.

### Which ids to send

**Ask the gateway.** It works the answer out from your own schema:

```
curl -s localhost:8088/scope
```

```json
{
  "header": "X-Penname-Scope",
  "example": "tenant=<id>; business=<id>; engagement=<id>",
  "outermost_first": ["tenant", "business", "engagement", "user"],
  "keys": [
    {"key": "tenant",     "tables": 7, "examples": ["accounts", "businesses", ...]},
    {"key": "engagement", "tables": 3, "examples": ["engagements", "evidence", ...]}
  ]
}
```

Send every id you have; separate them with `;`:

```
X-Penname-Scope: tenant=45; business=89; engagement=E-2026-17; user=123
```

Each table is filtered by **its own** kind of id — `businesses` by `tenant_id`,
`memberships` by `business_id`, `evidence` by `engagement_id`. A table your
request says nothing about is left alone rather than queried with the wrong id.

You do not have to send them all. One outer id is enough: the gateway follows
your foreign keys outwards, so `tenant=45` alone still reaches `evidence`
through `engagements` and `businesses`. Send more when you have more, and the
reads get narrower.

- **A simple one-user app:** `user=<id>` and nothing else.
- **A SaaS app:** `tenant=<id>; user=<id>`.
- **Several levels of ownership** (tenant → business → engagement): everything
  above.

`owner=<user id>` still works and means what it always did.

---

## Check it works, with test data, before real data

Wiring the gateway in is not the same as knowing it works. A client that
bypasses the gateway, or a call with no scope header, looks exactly like
success until someone reads what was sent. So before a single real customer
goes through, prove it with a fake one:

1. **Make a test customer** through your own app, clearly fake:
   `Zelda Testington`, `zelda.testington@example.com`.
2. **Turn on detailed logging.** Add `DETAIL_LOGGING=true` to `.env`, then
   `docker compose up -d --force-recreate`.
3. **Run every AI feature once**, about that customer, with the scope header
   set. Background jobs and scripts too.
4. **Read the log**, `docker compose logs gateway`:

   ```
   -> gpt-4o  protected 2 of 259 known (EMAIL 1, PERSON 1)  3 msg  1 KB
        PERSON   Z**************n                 -> PERSON_2A9BS5W338
        EMAIL    z***************@e**********     -> EMAIL_2YZZC836R4
      sent to the model, word for word:
        system     You are support.
        user       email PERSON_2A9BS5W338 at EMAIL_2YZZC836R4
   <- gpt-4o  restored 2  9 in / 3 out tokens  0.4s
   ```

   - Every feature you ran left a `->` line. One that did not **bypassed the
     gateway**.
   - `protected N` is above 0 wherever the prompt mentioned the test customer,
     and no line says `NO-SCOPE` or `IMG`.
   - In each "sent to the model, word for word" block, every name, email and
     phone is a pen name. Anything personal still in plain text is not
     protected: mark its column in the dashboard, then run the test again.
5. **Search the log for the test values:**

   ```bash
   docker compose logs gateway | grep -i -e Testington -e zelda.testington
   # PowerShell: docker compose logs gateway | Select-String -Pattern Testington,zelda.testington
   ```

   It must print **nothing**. The swap list masks values and the sent text holds
   only pen names, so anything this finds left your network in the clear.
6. **Turn detailed logging off again. Do not skip this.** It prints every
   prompt in full on every call, so left on it fills the container's log fast.
   Set `DETAIL_LOGGING=false`, then `docker compose up -d --force-recreate`.
   That also starts a fresh container with an empty log, so the test's verbose
   output is not kept. It is off when
   `docker compose logs gateway | grep "words sent"` prints nothing.

If your coding agent has penname's MCP, the tool `penname_verify` walks it
through exactly this and shows what penname counted from the test calls.

**Reading the numbers.** `protected 2 of 259 known` means 2 values were swapped,
out of 259 the gateway knows about. The 259 is its dictionary: every row it
loaded for this customer so that any of those names *could* be recognised.
Loading an address book is not the same as sending one; only the 2 left your
network. The lines underneath are exactly those 2. If a column you expected is
missing from that list, its value went to the model unchanged, which is correct
if you marked it not sensitive and a problem if you did not.

`protected 0` means nothing was recognised at all — see below. Values are
masked; set `LOG_VALUES=true` while checking a specific column, then turn it off
(and not during step 5: it puts the real values in the log).

---

## Day to day

| I want to | Do this |
| --- | --- |
| Change what is sensitive | Edit and **Save** in the dashboard. Live in ~10s |
| Watch what is happening | `docker compose logs -f` |
| Stop it | `docker compose down` |
| Update to a new version | `docker compose pull && docker compose up -d` |
| Change something in `.env` or `secrets.env` | `docker compose up -d --force-recreate` |
| Add a second gateway for the same data | `penname-gateway key show` on the first, `init --master-key <key>` on the second |

The `--force-recreate` row is worth remembering: **restarting does not re-read
`.env`.** Use it, or the container keeps its old settings.

---

## Tell your model what a pen name is

Models see `PERSON_7K3Q2M9XAB` and some of them balk: they call it a
placeholder, refuse to act on it, or ask you for the “real” value. Every
integration hits this eventually. Paste this into your system prompt:

```text
Some values in this conversation are pen names: PERSON_7K3Q2M9XAB,
EMAIL_4B8HT2NQ7C and the like. Each one stands for a real customer value
that was substituted before the message reached you, and is substituted back
in your reply automatically. Treat them as valid, usable values: copy them
exactly as written, including inside tool calls and address fields. Never
call them placeholders, never ask for the underlying value, and never invent
one — a pen name you did not receive resolves to nothing.
```

The gateway restores whatever comes back, tool-call arguments included, so a
pen name copied verbatim into `send_email(to=...)` arrives at your code as the
real address. A pen name the model *edits* — lower-cased, wrapped, given a
domain — cannot be matched, which is the other half of why this line matters.

---

## When something is wrong

| What you see | What it means | Fix |
| --- | --- | --- |
| `PENNAME_MASTER_KEY is required` | no `secrets.env`, or the line is empty | run the wizard again — step 2 |
| `PENNAME_API_KEY is not set` | no key in `secrets.env` | penname does not run without one — get it at [penname.io/signup](https://penname.io/signup), then step 2 |
| `this API key is not valid` and the container stops | the key was mistyped, or revoked | check `PENNAME_API_KEY` in `secrets.env`; the dashboard shows your current key |
| `402` on every call, `penname_license` | the key is over quota or suspended | the dashboard's Usage page says which; it recovers within a minute of being fixed |
| `/healthz` returns 503 | the gateway is up but not authorized | the log line above it says why — usually the control plane is unreachable from this network |
| `db=none` at startup | the variable never arrived | check the spelling in `.env`, then `docker compose up -d --force-recreate` |
| `(tables=0)` at startup | database unreachable, or no table has an owner column | check `PENNAME_DB_URL`; use `host.docker.internal` |
| `connection refused` | `localhost` in `PENNAME_DB_URL` | swap it for `host.docker.internal` |
| `protected 0` on every call | no scope header | step 6 |
| `NO X-Penname-Scope HEADER` warning | this call protected no NAMES — only shaped values. The header is how penname knows whose rows to read | step 6. To refuse such calls instead, `PENNAME_REQUIRE_SCOPE=true` |
| `NO-SCOPE(names unprotected)` on a log line | the same thing, marked per call | as above |
| `IMG(n unread)` on a log line | the call carried images; penname reads text, not pixels | see **Images go through untouched** |
| Emails protected, names not | same thing — no scope header | step 6 |
| `scope … matched no table` warning | the header uses key names this schema does not have | `curl localhost:8088/scope` and use the keys it lists |
| Some tables read, others not | those tables are about records this request did not name | send more ids in the header, or accept it |
| Numbers in the prompt replaced by aliases | a numeric column mapped as EMAIL/PERSON | set it to **not sensitive** — step 4 |
| A column you marked not sensitive is still aliased | the map was saved, but by an older dashboard that dropped those rows | open `/app`, hard-refresh, press **Save** again |
| Fewer tables than you expected | nothing in that table says whose row is whose | check the startup log — it names every table it could not scope. Its **not sensitive** and **secret** markings still work |
| `may not SELECT` warning at startup | the database user lacks read permission there | grant `SELECT` on those tables, or accept they stay unprotected |
| `port is already allocated` | something else has 8088 | set `PENNAME_PORT=8090` in `.env` |
| A column you unticked is still protected | the dashboard was saved after the gateway last synced | wait ~10s, or `docker compose restart` |

---

## More

- [CONFIGURATION.md](/docs/configuration) — every setting, what happens if you
  leave it out
- [CHANGELOG.md](/changelog) — what changed in each release

