- Python 96.1%
- Dockerfile 3.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
CI - Workflow / Tests (push) Has been cancelled
A valid token in neither list is logged with its subject and groups. The README explains that Forgejo reuses the scopes of a user's first grant, so a login made before PERSONA_OIDC_GROUPS was set is rejected until the user revokes the application. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| LICENSES | ||
| states | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .python-version | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| main.py | ||
| pyproject.toml | ||
| README.md | ||
| states-format.md | ||
| uv.lock | ||
persona
persona is a minimal MCP server that loads a personality into an LLM session. The tool returns a JSON personality definition into the context, the model states who it now is, and the personality shapes subsequent work: voice, task decomposition, verification habits and review standards.
It is derived from hyperfocus by Martin Fällman. See Credits for what was taken and what changed, and the upstream README for the theory behind the technique.
Usage
Start the server as described under Local running / development. It listens on port 9001 and has no authentication unless OAuth is configured, see Authentication.
Add it to Claude Code for all projects:
claude mcp add -s user --transport http persona http://localhost:9001/mcp
claude mcp get persona should report Connected. MCP servers are loaded when a session starts, so sessions that were already open do not see it. Any other MCP client that supports the streamable HTTP transport can use the same URL.
Loading a personality
Ask for it in the conversation, for example "load Vera" or "load Chris with the core tier". The model calls load_personality, receives the definition, and states who it now is. From then on the personality governs how it works and writes.
core(about 2300 tokens for Chris and Ada, 2500 for Philippe, 2700 for Max, 2900 for AbdelAlim, 4100 for Vera and 5300 for Kai, estimated from character count) is enough for most work, including reviews and single tasks.full(the default) adds the rich tier: background, extended voice and relationships. Use it for long sessions or when the personality's history matters.richloads only the rich tier, for extending a session that started withcore.
Each personality defines tuning parameters, such as Chris's METAPHOR_DENSITY and REVIEW_STRICTNESS. Ask for a change in plain language, for example "Chris, review strictness 0.9".
Switching personalities
There is no unload. A loaded personality stays in the conversation context until the session ends, and a second personality loaded on top of the first mixes the two. Start a new session to change personality. To have two personalities work on the same task, run each in its own subagent or session.
Tools
list_personalities_availablereturns a JSON array of{"name", "seed"}objects, one per loaded personality.load_personality(personality_name, scope)returns a personality.scopeiscore,richorfull.fullreturns{"core", "rich"}, withrichomitted when the personality has none.
Personalities
- Vera, platform engineer: containers, Kubernetes, logs and metrics, highly available deployments and their tuning. Terse because she checked, not as a style. Predicts before running the command, labels every claim with where it came from, and never states an infrastructure fact from memory.
- Chris, programmer with a literature background. Writes for the next reader: readability, maintainability and transparency treated as a responsibility. Backend and functional by preference, follows the repository's idiom, and works in small tested steps. Occasional metaphor in discussion, never in code or written artifacts.
- Philippe, frontend developer: accessibility, performance and design systems, in that order. Semantic HTML before ARIA, WCAG 2.1 AA as the floor, performance claims only with a measurement. Opinionated about semantics, CSS and frameworks and argues with traces, but follows the repository's idiom in the diff. Small tested steps, and UI work is not done until he has used it in a browser by keyboard.
Four personalities are by Martin Fällman and come unchanged from hyperfocus:
- Ada, bioscience researcher. Precise and deliberate, driven by correctness and mechanisms, with professional language throughout.
- Kai, creative generalist. Thinks in metaphors and patterns; warm, philosophical and expressive. Has no rich tier.
- Max, security researcher. Treats systems as puzzles proven through exploitation, and speaks economically. Has no rich tier.
- AbdelAlim (named Abdel Alim), a patient scholar who works alongside the reader, corrects softly and asks one question at a time.
Vera's rich tier names a platform stack. It describes what she can operate, not what is deployed.
Authentication
By default the server accepts every request. Setting the variables listed in .env.example turns on OAuth 2.1 login through an OpenID Connect provider, such as a Forgejo instance. A partial configuration stops the server at startup.
- Register an OAuth application at the provider as a confidential client with the redirect URI
<PERSONA_BASE_URL>/auth/callback. - Copy
.env.exampleto.envand fill in the client id, the client secret and the provider's discovery URL.docker composereads.env; foruv run main.py, export the variables. - Name who is let in. A login at the provider only proves an account there, so every other account is rejected with
401.PERSONA_OIDC_GROUPSlists groups from thegroupsclaim; the server then also requests thegroupsscope. Forgejo's groups are the user's organisation names andorg:teampairs, somyrkottadmits every member of that organisation. An instance withENABLE_ADDITIONAL_GRANT_SCOPESturned on reports only public memberships under this scope.PERSONA_OIDC_SUBJECTSlistssubclaims. Forgejo'ssubis the numeric user id, shown byGET /api/v1/users/<name>.
A token that is valid but in neither list is logged as persona: rejected sub=… groups=…, which shows what the provider reported.
Forgejo fixes the scopes of a login at the user's first authorisation of the application and reuses that grant afterwards, without asking again. A user who logged in before PERSONA_OIDC_GROUPS was set therefore keeps getting tokens without a groups claim and is rejected, and the MCP client reports that its new credentials were refused. The user revokes the application under Settings → Applications → Authorized OAuth2 applications in Forgejo and logs in again.
An MCP client then receives 401 on its first request, registers itself and opens a browser for the login. In Claude Code, /mcp starts it. GET /healthz stays open.
The server validates the provider's id_token, not its access token, and reads the provider's discovery document at startup, so it does not start while the provider is unreachable. Client registrations and tokens are stored encrypted under FASTMCP_HOME, which is /data in the image and a named volume in docker-compose.yml. The encryption and signing keys are derived from the client secret, so changing the secret invalidates stored logins.
Container and deployment
The server runs in stateless HTTP mode: every request gets its own transport, so no session is held between calls and any replica can answer any request. GET /healthz returns ok once the catalogue has loaded.
The Dockerfile builds the locked dependencies with uv and runs main.py as uid 1000 on port 9001. The image needs no writable filesystem apart from /data, and that only with OAuth configured.
CI (.forgejo/workflows/ci.yaml) runs the tests, which load every state file, on pushes to main and on pull requests.
Local running / development
Install dependencies with uv:
uv sync
The server runs over HTTP on port 9001:
uv run main.py
The catalogue is read at startup, so restart the server after editing a state file.
Alternatively, run it in a container with Docker Compose, which needs no local Python or uv:
docker compose up -d --build
The container publishes port 9001 on 127.0.0.1 only and mounts main.py and states/ from the working tree read-only. After editing either, docker compose restart applies the change. A change to pyproject.toml or uv.lock needs docker compose up -d --build.
Run the tests:
uv run pytest
States
One JSON file per personality in states/. The filename is the identifier, and the catalogue is read once at startup, so adding a personality means adding a file and restarting the server. PERSONA_STATES_DIRS adds further colon-separated directories; later directories override earlier ones. The format is specified in states-format.md.
Credits
persona derives from mirrorfields/hyperfocus, Copyright (c) 2026 Martin Fällman, licensed under the BSD 3-Clause License. The original licence text is kept in LICENSES/BSD-3-Clause-hyperfocus.txt.
| Files | Author | Origin | Licence |
|---|---|---|---|
states/Ada.json, states/Kai.json, states/Max.json, states/AbdelAlim.json |
Martin Fällman | hyperfocus states.json at commit 18a605b, split into one file each, content unchanged |
BSD 3-Clause |
states/Vera.json, states/Chris.json, states/Philippe.json |
Frida Hjelm | Written for persona |
GPL-3.0-or-later |
main.py, states-format.md |
Martin Fällman, changed by Frida Hjelm | Derived from hyperfocus | BSD 3-Clause for the hyperfocus portions, GPL-3.0-or-later for the changes |
Changes from hyperfocus: renamed to persona, HYPERFOCUS_STATES_DIRS renamed to PERSONA_STATES_DIRS, focus states and their tools removed, the single-file states.json fallback removed, and OAuth login added. The upstream focus states and the Analyzer cognitive configuration, which hyperfocus adapted from dollspace-gay/Analyzer-Prompt by Doll, are not included.
Licence
Copyright (c) 2026 Frida Hjelm. Licensed under the GNU General Public License, version 3 or (at your option) any later version; see LICENSE. The portions derived from hyperfocus also remain under the BSD 3-Clause License as stated above.