penname.

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_...:

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


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:

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:

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:

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

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:

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:

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

Anthropic / Claude:

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:

Two ways to check you have them all:

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

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
{
  "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.

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:

    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:

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