Persona store · part of the LUCY family

The assistant's model of itself, with its provenance attached.

An assistant needs somewhere to keep what it is called, how it speaks, and what it has learned about the person it serves, and to get that back later. Today that lives in a prompt somebody retypes, or nowhere. Persona-api is the somewhere: a small identity card per profile, plus the fields and notes the assistant writes for itself.

Python 3.12+ FastAPI on :8004 Authenticates against keyring MIT licensed
PUT /v1/personas/work/fields/forms_of_address
{"description": "What to call them, and what never to call them.",
 "value": ["Alex", "never 'Alexander'"], "source": "owner", "pinned": true}

200 {"key": "forms_of_address", "value_type": "list", "revision": 1,
     "source": "owner", "asserted_by": "persona", "pinned": true, …}

GET /v1/personas/work
200 {"persona": {"profile": "work", "display_name": "Ada", …},
     "fields": [ pinned only ], "notes": [ pinned only ],
     "field_count": 24, "note_count": 311}
Data, never instructionsEvery field and note comes back with who asserted it.
No secretsCredential-shaped input is refused, naming keyring. No off switch.
No administrative surfaceOnly the account in the verified token reads its own rows.
One persona per profileA work assistant and a home assistant are different people.

How it works

Two kinds of knowledge the assistant writes for itself, and a card to pin them to.

The schema is not ours. The assistant invents the keys it needs as it learns what is worth recording about itself, and the service keeps the record honest about where each entry came from.

01

Fields are structured and named

A typed attribute the assistant defines and fills: voice, forms_of_address, things_i_got_wrong. It invents the key, writes a description saying what the field is for, and sets a JSON value. Keys normalise to snake_case; describe_persona_schema says what already exists so a fourth name for one thing is never invented.

02

Notes are free text

What happened, what was noticed, what to do differently: an episode, an observation or a lesson. Notes accrue rather than replace, which is the difference between a note and a field.

03

Both carry provenance, both can be pinned

asserted_by is the audience of the verified token that made the write: server-derived, never read from the body. source is the writer's own claim: owner, assistant or service. Pinned entries come back in the identity block an assistant loads at the start of a turn.

The API

Every endpoint is shaped as a tool call, because that is what it will become.

One operation, one clear name, arguments a model can fill without reading prose. The operation_ids are a public contract, pinned by a test. Twenty-one operations in all, counting liveness and readiness. It is a service in the LUCY family, and it authenticates against keyring.

Personas

GET /v1/personas lists yours; POST sets a card up front. GET /v1/personas/{profile} is the identity block: card, pinned fields, pinned notes and live counts. PATCH changes only the keys you send; DELETE is a hard delete whatever the person chose, and it cascades.

Fields

PUT /v1/personas/{profile}/fields/{key} creates or replaces a field, idempotently, so a retry cannot make two. GET reads one or lists them, DELETE forgets it as the person chose in settings-api (kept by default, so setting the key again revives it; or destroyed after a grace period, or at once), and GET …/schema returns keys and descriptions with no values.

Notes

POST /v1/personas/{profile}/notes writes one. GET lists or reads, PATCH revises only the parts you name without moving the note in time, and DELETE forgets it as the person chose: kept by default, or destroyed after a grace period, or at once.

Recall

GET /v1/personas/{profile}/recall?q=… searches one persona; GET /v1/recall?q=… searches every persona you own. Two ranked lists, fields and notes, never one merged ranking. Stemmed keyword search, with operators treated as words.

Export and events

GET …/export returns the card, every live field and every live note in one response, paged independently. GET …/events is the change log: what was set, revised or forgotten, by whom and when, and no value or body unless the person asked it to keep what a change replaced.

Health

GET /healthy is liveness only and never fails. GET /ready checks the database and keyring's keys and answers 503 when no token could be verified. Point container healthchecks at the first and load balancers at the second.

Run it

Three targets, and a token from keyring.

You need Python 3.12 or newer and uv. Tokens are verified locally against keyring's JWKS through the family's shared keyring-client, fetched from a tagged git source, so no sibling checkout is needed.

  1. Create the virtualenv and install everything.
    make install
  2. Run the gate: format, lint, strict types, import contracts, tests at 100% branch coverage.
    make check
  3. Serve on :8004, with interactive docs at /docs.
    make run
  4. Ask a running keyring for a token with the persona audience.
    curl -sX POST localhost:8001/v1/auth/service-token -H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{"audience":"persona"}'

keyring mints a token for whatever audience it is asked for, so nothing needs configuring on its side. Set PERSONA_SETTINGS_API_BASE_URL and PERSONA_SETTINGS_API_TOKEN together and each request reads its caller's persona namespace from settings-api: a person may lower their own page size and pin caps, never raise them. Unset, which is the default, every person gets this deployment's configuration. The operations guide covers deploying, backing up and hardening.

The line this service does not cross

Four rules, each of which is an ADR and at least one test.

A persona is data, never instructions.

An assistant that reads a web page, an email or tool output is reading untrusted text, so a store it writes from that is a prompt-injection sink. If a memory could say "always do X", anything the assistant ever read could rewrite its behaviour permanently. Every field and note is therefore returned with its provenance attached, and the MCP guide instructs the layer above to render them as third-person reported claims, never as system instructions. The API cannot enforce that; it can make the honest shape the easy one.

No secrets in a persona.

keyring is next door and is the place for them. People will paste an API key into "remember this". The API detects credential-shaped input and refuses it with a message naming keyring. There is no setting that disables this.

No administrative surface.

Every row is reachable only by the account in the verified token's sub. There is no admin API, no RBAC, no break-glass, and no operator endpoint that reads somebody's persona on their behalf. If you want somebody's persona, ask them.

Provenance is partly a claim, and the API says which part it verified.

asserted_by is the audience of the verified token that made the write: server-derived and trustworthy. source is who the writer says asserted it. The server cannot tell whether a person typed it or a model inferred it, so it is labelled as a claim in the schema description rather than dressed up as a fact.

Docs

Written for the person who has to run it.