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
- Before you start
- What penname can see
- The two keys
- Building with Lovable, Base44, Cursor or Claude Code?
- Step 1 — Get your API key
- Step 2 — Run the setup wizard
- Step 3 — Start the gateway
- Step 4 — Say what is sensitive
- Step 5 — Point your app at the gateway
- Step 6 — Tell it whose data this is
- Check it works, with test data, before real data
- Day to day
- Tell your model what a pen name is
- When something is wrong
- More
The fastest way: sign up, then let your agent do it
-
Get a key — open https://penname.io/signup, create an account (no payment), and copy the
pk_...key it shows you. -
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:
- 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:
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:
- Your
pk_key. A key the control plane refuses writes nothing, so a dead key cannot leave you with a setup that looks finished. - Your database URL, for a read-only user — or Enter to skip for now.
localhostis rewritten tohost.docker.internalfor you: inside a container,localhostmeans 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:
db=set— it readPENNAME_DB_URL.db=nonemeans the variable never arrived; check the spelling in.envand 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.
- Open
https://penname.io/app - Paste your
pk_...key, press Sign in - Press Auto-suggest — a starting point, not an answer
- Correct it. Each column is
PERSON,EMAIL,PHONE,NATIONAL_ID,SECRET, or not sensitive - 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:
- 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:
# 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.
- 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:
-
Make a test customer through your own app, clearly fake:
Zelda Testington,zelda.testington@example.com. -
Turn on detailed logging. Add
DETAIL_LOGGING=trueto.env, thendocker compose up -d --force-recreate. -
Run every AI feature once, about that customer, with the scope header set. Background jobs and scripts too.
-
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 Nis above 0 wherever the prompt mentioned the test customer, and no line saysNO-SCOPEorIMG.- 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.
- Every feature you ran left a
-
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.testingtonIt 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.
-
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, thendocker 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 whendocker 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
- CONFIGURATION.md — every setting, what happens if you leave it out
- CHANGELOG.md — what changed in each release