penname.

Configuring the gateway

Every setting, what to put in it, and what happens if you leave it out.

Set them in a .env file next to docker-compose.yml (copy .env.example to start). Running without Docker? The same names work as ordinary environment variables, and penname-gateway --env-file .env reads the same file.

Two are required: PENNAME_API_KEY and PENNAME_MASTER_KEY. Everything else has a working default.

Writing the .env file

NAME=value, one per line. No quotes, and no spaces around the =.

PENNAME_MASTER_KEY=7f3a9c2e...
PENNAME_CONTROL_PLANE_URL=https://penname.io
PENNAME_DB_URL=postgresql://readonly:pass@host.docker.internal:5432/yourdb

Not this:

PENNAME_CONTROL_PLANE_URL: https://...     # that is YAML, not .env
PENNAME_CONTROL_PLANE_URL = "https://..."  # spaces and quotes both wrong

You never edit docker-compose.yml. A line in it like

PENNAME_CONTROL_PLANE_URL: ${PENNAME_CONTROL_PLANE_URL:-https://penname.io}

only means "take this one from .env". Everything you configure lives in .env.

A # starts a comment. A value with a # in it — some passwords — does need quoting: PENNAME_DB_URL='postgresql://u:pa#ss@host/db'.


Where secrets live

penname-gateway init writes two files on purpose:

File Holds Share it?
.env settings: port, provider, control plane URL yes — paste it into a support ticket freely
secrets.env PENNAME_MASTER_KEY, PENNAME_API_KEY, PENNAME_DB_URL never

secrets.env is readable by you only, listed in .gitignore, and reaches the container through env_file: — not through a ${...} variable that compose would happily interpolate from anywhere. Keeping the three secrets in a file of their own is what makes "send me your .env" a safe thing for support to ask.

On a platform with a real secret store — Kubernetes, Docker Swarm, AWS or GCP secret managers — hand each one over as a file instead:

PENNAME_MASTER_KEY_FILE=/run/secrets/master_key
PENNAME_API_KEY_FILE=/run/secrets/api_key
PENNAME_DB_URL_FILE=/run/secrets/db_url

The gateway reads the file's contents (trailing newline ignored). Setting both the variable and the _FILE to different values is refused at startup rather than resolved one way in silence. A mounted secret never shows in docker inspect, the shell history or the process list, which is where an environment variable ends up on show.

Running more than one gateway for the same data — production and staging, or several replicas — every one of them must use the same master key, or the same person gets a different pen name from each. Print it from an existing install and hand it to the next one:

penname-gateway key show                        # or: docker compose exec gateway penname-gateway key show
docker run -it -v ${PWD}:/setup pennameio/gateway init --master-key <that key>

What none of this does is encrypt the key at rest: the gateway has to read it to start, so anything it could decrypt, an attacker on the same machine could too. Disk encryption is the operating system's job — BitLocker, FileVault, LUKS — and it is the right place for it.


PENNAME_MASTER_KEY — required

The secret your pen names are derived from. Same key plus same person plus same scope always gives the same alias, on any machine, with nothing stored.

PENNAME_MASTER_KEY=7f3a...   # 64 hex characters

Generate one, once:

openssl rand -hex 32
# no openssl (Windows)?
docker run --rm python:3.12-slim python -c "import secrets; print(secrets.token_hex(32))"

Treat it like a database password. Store it wherever you keep those.


The database the gateway reads to learn which values are sensitive. Paste the URL your application already uses.

PENNAME_DB_URL=postgresql://readonly:pass@host.docker.internal:5432/yourdb

Use a read-only database user. The gateway only ever runs SELECT, and a read-only user is how you make that true rather than trusted.

In Docker, localhost means the container, not your machine. If your database runs on your host, use host.docker.internal instead. That name resolves on macOS and Windows automatically, and docker-compose.yml adds it on Linux too, so one .env works everywhere.

An async driver is fine — postgresql+asyncpg://, mysql+aiomysql:// and the rest are swapped for their synchronous equivalent automatically, and the swap is logged rather than done silently.

Leave it out and only emails, phone numbers and Israeli ID numbers are protected. Those have a recognisable shape; names do not. Without a database Dana Levy reaches the model unchanged. This is the single setting that decides whether your customers' names are protected.


PENNAME_ANTHROPIC_BASE_URL

Where /v1/messages is forwarded — the Claude half of the gateway.

PENNAME_ANTHROPIC_BASE_URL=https://api.anthropic.com/v1     # the default

Separate from PENNAME_UPSTREAM_BASE_URL on purpose, so one gateway serves both providers: an app that switches from GPT to Claude changes its own client and nothing else. Point an Anthropic SDK at http://localhost:8088 (no /v1) and it works exactly as the OpenAI SDK does at http://localhost:8088/v1.

Your Anthropic key is not configured here either — the gateway forwards the x-api-key header your app already sends.


PENNAME_UPSTREAM_BASE_URL

The model provider you actually use. Anything OpenAI-compatible.

PENNAME_UPSTREAM_BASE_URL=https://api.openai.com/v1     # the default

Your provider API key is not configured here. The gateway forwards whatever Authorization header your application already sends, so it never holds your OpenAI key and never needs to.

To see what the model would receive without sending anything anywhere, point this at penname-echo — see Checking it works.


PENNAME_API_KEY — required

Your penname key. Get one at https://penname.io/signup.

PENNAME_API_KEY=pk_live_...

The gateway does not run without it. It authorizes the key at startup and before every call, and a gateway that has never been authorized proxies nothing — it answers 402 and reports itself unhealthy. There is no setting that turns this off; leaving PENNAME_CONTROL_PLANE_URL out does not turn it off either (see below).

Two things are deliberately tolerant, because they are our problem and not yours:

The key is not your master key and not your OpenAI key — see the two keys in the README.


PENNAME_CONTROL_PLANE_URL — leave it unset

Where the key is authorized, your schema is uploaded (table and column names, never a row) and your field map comes from.

PENNAME_CONTROL_PLANE_URL=https://penname.io    # the default; you can omit it

Set it only if you run your own control plane. It is not a way to run without one: unset means the hosted control plane, not "ask nobody".

With it reachable, the gateway uploads your schema so the dashboard is not empty, then fetches the choices you saved and polls for changes, so a dashboard edit takes effect without a restart. Until you have marked anything, it reflects your schema and proposes a map itself — a reasonable start, but a guess, not your decision.


ENABLE_LOGGING — on by default

One line per request each way, plus which value became which alias.

ENABLE_LOGGING=true      # default; set false for silence
-> gpt-4o  protected 3 of 259 known (PERSON 1, PHONE 1, ILID 1)  4 msg  83 KB
     PERSON   D**a                  -> PERSON_BRHB6GR62Y
     PHONE    05*******9            -> PHONE_A0RVW3C5BY
<- gpt-4o  restored 3  21 in / 8 out tokens  0.4s

"3 of 259 known" means three values were actually swapped, out of 259 the gateway had loaded so it could recognise them. The dictionary is the whole address book; the first number is what left your network. Only the values that were swapped are listed underneath.

Values are masked: enough to see the right column was picked, not enough to identify anyone.


DETAIL_LOGGING — off by default

Also prints every word the model received, as plain text, under the swap list.

DETAIL_LOGGING=true
-> gpt-4o  protected 2 of 259 known (EMAIL 1, PERSON 1)  3 msg  1 KB
     PERSON   D*******y              -> PERSON_2A9BS5W338
     EMAIL    d********@e**********  -> EMAIL_2YZZC836R4
   sent to the model, word for word:
     system     You are support.
                Be brief.
     user       email PERSON_2A9BS5W338 at EMAIL_2YZZC836R4
     assistant  lookup({"who": "PERSON_2A9BS5W338"})
<- gpt-4o  restored 2  9 in / 3 out tokens  0.4s

The swap list is your value and what the model got instead; the text under it is the whole conversation exactly as the model read it. Only the words: no JSON, no request settings, no tool schemas, no reply. It is the protected copy, so it reads in aliases rather than customer data.

Still long: a real system prompt is printed in full per request, so left on it fills the container's log. Turn it on to see exactly what left, then turn it off: DETAIL_LOGGING=false and docker compose up -d --force-recreate, which also starts the new container with an empty log. While it is on, the startup line says logging on + words sent (DETAIL_LOGGING).

Use it before launch. Turn it on, send your AI features a fake test customer, and check that the log holds only pen names and never the test values. That is the check in Check it works, and it should pass before any real customer goes through the gateway.


LOG_VALUES — off by default

Prints the real value beside each alias instead of a mask.

LOG_VALUES=true
     PERSON   Dana Levy             -> PERSON_BRHB6GR62Y
     EMAIL    dana@example.com      -> EMAIL_W0JZFM75RM

Your log then contains customer data. Logs get shipped to log services, attached to support tickets, and pasted into chats — that is how this data escapes, far more often than through the model. The gateway prints a warning at startup while it is on.

Turn it on to diagnose a specific mapping, then turn it off.


PENNAME_PORT

Which port on your machine publishes the gateway. Docker only.

PENNAME_PORT=8088     # the default

Change it only if something else already has 8088 — you will know, because docker compose up fails saying the port is in use. The container keeps 8088 internally either way; only the published port moves.

The gateway is published on 127.0.0.1 only. It holds your master key and reads your database, so it has no business being reachable from the internet.


The scope header

A call with no scope header protects no names. Not fewer names — none. Emails, phones and ID numbers still go, because those have a shape a detector can find; a name does not, so the only way to protect one is to know whose row it is. The count in the log (protected 1 of 1) counts what was replaced and cannot see what it never knew about, which is why an install can look like it is working long before it protects anything.

Since 0.3.0 the gateway says so out loud:

To refuse those calls instead of reporting them, set PENNAME_REQUIRE_SCOPE=true (below). Every deployment handling real data should.

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

This is how the gateway knows which rows to read. Emails, phone numbers and ID numbers have a shape a detector recognises without it; names do not, so without this header names go to the model in the clear.

Do not guess the key names. Start the gateway and ask it:

curl -s localhost:8088/scope

It reads them off your own schema, and the same list is printed at startup:

scope keys (outermost first): tenant, business, engagement, user

Framework noise in a table name is stripped for you: auth_user answers to user, tbl_customer to customer, crm_contact to contact. The response also carries a database section — how many tables, whether relationships are declared, what kind of keys — so you can see what the gateway understood about your schema before trusting it.

Every table is filtered by the kind of id it actually holds — businesses by tenant_id, memberships by business_id, evidence by engagement_id. A table your request says nothing about is skipped, not queried with an id that belongs to something else.

You do not have to send every key. The gateway follows your foreign keys outwards, up to two tables, so tenant=45 on its own still reaches evidence through engagements and businesses. Send more ids when you have them and the reads get narrower and cheaper; send fewer and the coverage stays.

owner=<user id> keeps working and keeps meaning what it always meant.

What happens when the scope is wrong

A key no table uses ignored; the log warns scope … matched no table
A key that fits some tables and not others those tables are read, the rest are skipped
A text id where the column holds integers that path is skipped, not offered to the database. engagement=E-2026-17 is never sent to a user_id column
No header at all detectors only — shapes protected, names not

PENNAME_REQUIRE_SCOPE — off by default

PENNAME_REQUIRE_SCOPE=true

Refuse the call, 422, when the scope header matches nothing in the field map. Without it such a call still goes to the model, protected by detectors alone — and from the outside that looks exactly like a call that was fully protected. Turn it on once your app is sending the header, and a regression that drops it becomes an error you can see instead of a leak you cannot.

PENNAME_REQUIRED_SCOPE_KEYS=tenant,engagement

Stricter: name the keys this deployment is never protectable without. A request missing one is refused with Missing engagement scope — protection cannot be guaranteed, whatever else it carried.

PENNAME_SCOPE_NAMESPACE — the outermost key by default

Aliases are namespaced, so the same person is the same pen name across calls. That namespace has to stay still: if it were "every key in the header", a request that happened to mention an engagement would give a person a different alias from the request before it, and a conversation would stop restoring halfway through. So one key names the namespace — by default the outermost one the schema knows about, which is the one every request carries.

Set it explicitly if you would rather pin it (PENNAME_SCOPE_NAMESPACE=tenant), or to * for the old behaviour of using every key sent.


PENNAME_DB_URL and MongoDB

The URL's scheme picks the reader. Anything SQLAlchemy speaks is relational — PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, Oracle — with async schemes rewritten to their sync driver automatically. mongodb:// (and mongodb+srv://) selects the document reader:

PENNAME_DB_URL=mongodb://readonly:pw@localhost:27017/mydb

The URL must name the database. Collections are read as tables and top-level fields as columns, with the shape learned by sampling documents (a field must appear in the sample to be mappable). userId-style fields resolve to their collection the way user_id columns do; owner_id resolves to the people collection by convention. Everything above — the ownership axis, catalogues, membership collections, the field map from the dashboard — works identically; joins run as short chains of queries. Not yet read: fields nested inside sub-documents, and arrays.


PENNAME_MAX_JOINS — 2 by default

How far the gateway will join outwards to reach a scope column. Two hops covers data -> owner -> organisation. A schema whose axis is genuinely deeper raises it; the price is per-request join depth, so it is a choice, not a default.

Polymorphic ownership — handled, guarded

owner_id next to owner_type (Rails, Laravel) means the SAME id column points at different tables row by row — and owner_id=55 with owner_type='Organization' is not user 55. The pair never produces an unguarded read: each owner-capable table gets its own scope path with the type column bound into the query ("User", "user", "users" — the data decides the spelling), and the audit line on every load names the guard.


PENNAME_SCOPE_TIERS / PENNAME_SCOPE_IGNORE — usually not needed

The gateway reads the schema once for its shape: who the people are (users, accounts, whatever it is called here), what they can belong to (tenants, organisations, teams — proven by NOT NULL links and membership tables, however many levels deep), and which referenced tables are lookup lists that merely describe them. A lookup list — plans, roles, currencies — is never a scope key: narrowing by one would read other customers' rows wholesale. GET /scope shows every conclusion with its reason, in the ownership block.

Two facts no schema can state are yours to override, comma-separated table or key names:

PENNAME_SCOPE_TIERS=project        # "projects" IS a tenancy boundary here
PENNAME_SCOPE_IGNORE=regions       # "regions" is a label, not a boundary

PENNAME_ALIAS_STYLE — typed by default

What an alias looks like on the wire.

PENNAME_ALIAS_STYLE=typed     # PERSON_7K3Q2M9XAB, PHONE_A0RVW3C5BY   (default)
PENNAME_ALIAS_STYLE=neutral   # REF_7K3Q2M9XAB,   REF_A0RVW3C5BY

Typed aliases tell the model what each token IS — it can put the phone number in the phone field of a tool call and address the person by role. That is also their cost: the provider learns that your prompts contain phone numbers, names, bank accounts — the categories, never the values.

neutral removes even that. Every alias reads REF_<tag>; nothing in the payload says which was a name and which was an ID number. The trade is answer quality on tasks where the type mattered — the model sees three identical- looking tokens and has to work out from context which one to call.

The tag itself is identical in both styles (the kind is folded into the HMAC either way), so you can flip this setting without re-keying anything, and replies keep restoring. A prefix of some kind has to stay — restore has to find these strings again in whatever the model writes back.


Keywords — protect what no column holds

A paragraph column can carry a name that never appears as a structured value — "Met Kaplan Holdings' CFO", a project codename, a phrase that must not leave. The field map cannot reach inside prose, and a name has no shape a detector recognises. Keywords are you saying it directly: this string, wherever it appears, gets this treatment.

Set them in the dashboard (Field map → Keywords) — they reach the gateway with the next map poll, no restart — or pin them locally:

PENNAME_TERMS_FILE=/path/keywords.json
{"ORG": ["Kaplan Holdings"], "PERSON": ["Yosef Mizrahi"], "SECRET": ["Project Bluebird"]}

The kind decides the treatment: SECRET refuses any call carrying the value; NONE exempts it from the detectors (a support address that looks like an email, because it is one); anything else aliases it wherever it appears — inside paragraphs included — with the reply restored as usual. Keywords apply to every request, scope header or not, and the alias derives from the value itself, so it is stable with nothing stored. File and dashboard keywords are additive; a dashboard edit never erases a locally pinned term.

For names nobody typed anywhere — new people appearing in free text — see PENNAME_NER: best-effort statistical detection, never a guarantee.


What SECRET does — read this before marking a column SECRET

SECRET is not “protect this harder”. It means this value must never reach a model at all, and what happens then depends on PENNAME_SECRET_MODE:

Mode What happens to the call Default
redact the value is cut out of the prompt and the call goes on. The model sees [secret removed by penname] yes
block the whole call is refused with a 422 no
warn the value is sent anyway and logged loudly no

So by default a SECRET column does not stop your app — it silently removes that value from the prompt. If your product legitimately hands that value to a model, mark the column something else. A signing link is the example that cost a customer an afternoon: penname proposes SECRET for signing_token (anyone holding it can sign), and an assistant whose job is to send signing links then finds the link deleted from its prompt, with no error to explain it.

Values shorter than 8 characters can never block or be redacted — a column named password_reset_channel holding sms is reported at startup as a weak secret instead, because blocking the word “sms” would take the product down.


PENNAME_SCHEMA_ONLY -- off by default

Read the column names, never the rows.

PENNAME_SCHEMA_ONLY=true

The gateway still reflects your schema, so the dashboard works and you can mark columns sensitive -- but it never runs a SELECT for data. Emails, phone numbers and ID numbers stay protected, because a detector recognises them by shape in the prompt itself.

Names do not. A name is ordinary text; the only way to know a given string is a customer's name is to have read it from your customer table. Turn this on and names reach the model unchanged. The gateway prints a notice saying so at every startup, so nobody inherits this setting without knowing what it costs.

Worth being clear about what this does and does not change: the gateway is a proxy, so it holds your prompt in memory either way. This setting stops it reading your database, not reading your traffic. What it never affects is the hosted dashboard, which receives schema and token counts and never content.


Everything else

Working defaults; most people never set these.

Setting Default What it does
PENNAME_MAP_POLL_SECONDS 10 How often to ask the dashboard whether the field map changed. One integer per ask, so this is cheap. Floor of 5.
PENNAME_MAP_REFRESH_SECONDS 300 Full re-fetch of the map even when the version looks unchanged, so a missed edit heals itself.
PENNAME_MAP_FILE none Read the field map from a local JSON file instead of the dashboard — for pinning the map in version control, or keeping the last-known one when the dashboard is unreachable. It is not a way to run without a key.
PENNAME_TERMS_FILE none Keywords from a local JSON file ({"ORG": ["Kaplan Holdings"]} or [{"kind","value"}]), additive to the dashboard's. See Keywords below.
PENNAME_SCOPE_HEADER X-Penname-Scope The header your app sends to say whose data a request is about. Rename it only if it collides with something.
PENNAME_REQUIRE_SCOPE off Refuse a call whose scope header matches nothing in the field map, instead of sending it with only the detectors. See below.
PENNAME_REQUIRED_SCOPE_KEYS none Comma-separated keys this deployment cannot be protected without, e.g. tenant,engagement. A request missing one is refused.
PENNAME_SCOPE_NAMESPACE outermost key Which scope key names the aliases. Set it to pin one (tenant), or to * for the pre-scope-keys behaviour of using every key in the header.
PENNAME_CUSTOMER_ID customer Labels this gateway in its own logs. Cosmetic.
PENNAME_NER off Best-effort machine detection of names the field map missed. Needs the ner extra (large: transformers + torch). A guess, never a substitute for the map.
PENNAME_NER_MODEL built-in Which NER model to load, when PENNAME_NER is on.
PENNAME_SECRET_MODE redact What a SECRET value in a prompt does: redact cuts it out and sends the rest (never stops your app, never leaks it), block refuses the call, warn sends it anyway and logs.
PENNAME_ANTHROPIC_BASE_URL https://api.anthropic.com/v1 Where /v1/messages is forwarded, so one gateway serves both providers.
PENNAME_REPORT_USAGE on Send token counts (never content) to the control plane, so your Usage page can show your own spend. Set false to send nothing — it silences the usage page, it does not turn the licence check off.
PENNAME_OFFLINE_GRACE_SECONDS 86400 How long an already-authorized gateway keeps serving when the control plane is unreachable. A day, so an outage on our side never stops your product. Before the first successful authorization there is no grace.

Checking it works

curl http://localhost:8088/healthz

"tables" is the number that matters: non-zero means it read your schema and worked out which columns hold customer data. Zero means it found no table it could scope to a user — the field map will fix that.

Then send one real request and read the log. protected 3 of 259 known (PERSON 1, PHONE 1, ILID 1) tells you it worked; protected 0 tells you it did not. The second number is the dictionary size, not what was sent.

To see what the model would have received without sending anything at all, point the gateway at the bundled fake provider:

penname-echo --port 9099
# then, in the gateway's .env:
PENNAME_UPSTREAM_BASE_URL=http://host.docker.internal:9099/v1

penname-echo prints every message it receives and forwards nothing. With --expect "Some Real Name" it becomes a pass/fail check: it reports any value that reaches it that should not have.