the past is just a story we tell ourselves.
persona (or "Per") is an AI agent akin to the Claw family but with humbler goals.
it is built on HCP, a hook contract which decouples it from harness internals and gives
it the ability to self-modify. hmux runs those hooks as its hcp face, so the same
self-model composes across whichever harness is active.
persona runs fully sandboxed in a container, served by
hmux as its single control plane: one image drives
hmux up, exposing an admin console, a voice ui, and an opencode-compatible face for the tui.
the harness underneath is pi (canonical) or opencode.
persona has a heartbeat mechanism, a simple default SOUL.md, task tracking, and a journal.
TAXONOMY.md (in the hmux repo) maps every concept across the three layers -- HCP the protocol, hmux the host, persona the agent -- including which words mean different things in each.
it supports most models and providers as well as most subscriptions (for now).
persona ships as a published container image: khimaros/persona:latest on docker hub
(docker.io/khimaros/persona), mirrored less often to ghcr.io/khimaros/persona. amd64 only
today. you need docker or podman and nothing else -- no checkout, no build, no toolchain.
persona's pi backend reads its providers from ~/.pi/agent/models.json,
~/.pi/agent/settings.json (enabled models + defaults) and ~/.pi/agent/auth.json (keys and
oauth) -- the same files the pi cli uses. every recipe below mounts those three read-only at
/config, and the entrypoint copies them into the container's /data on each boot. prepare
them as usual first, e.g. with llama-update-models from
llama-tools.
a provider running on your own machine must be reachable FROM the container. add
--add-host=host.docker.internal:host-gateway to the run commands (or extra_hosts: in
compose) and name that host in models.json.
persona starts without any of this -- the portal comes up, it just has no model to talk to -- but DROP THE THREE MOUNTS if the files do not exist yet: podman refuses a bind mount whose source is missing, and docker silently creates a directory where a file was meant to be. see model setup for hosted providers and oauth.
pick a runtime. all four do the same thing: one container, one published port (4280), and two
named volumes -- /work, the workspace persona self-edits, and /data, its home (config,
auth, sessions). both survive rm and re-run, so an upgrade keeps everything.
docker run -d --name persona --restart unless-stopped \
-p 4280:4280 \
-v persona-work:/work \
-v persona-data:/data \
-v ~/.pi/agent/models.json:/config/models.json:ro \
-v ~/.pi/agent/settings.json:/config/settings.json:ro \
-v ~/.pi/agent/auth.json:/config/auth.json:ro \
docker.io/khimaros/persona:latest
the same command, verbatim -- rootless. the registry is spelled out because podman does not assume one:
podman run -d --name persona --restart unless-stopped \
-p 4280:4280 \
-v persona-work:/work \
-v persona-data:/data \
-v ~/.pi/agent/models.json:/config/models.json:ro \
-v ~/.pi/agent/settings.json:/config/settings.json:ro \
-v ~/.pi/agent/auth.json:/config/auth.json:ro \
docker.io/khimaros/persona:latest
--restart holds while the machine is up; to start at boot, wrap it in a quadlet unit.
save this as compose.yaml in a directory of its own -- it names the published image, so it
needs no checkout and never builds:
services:
persona:
image: docker.io/khimaros/persona:latest
container_name: persona
restart: unless-stopped
ports:
- "4280:4280" # the hub. every ui hangs off this one port
volumes:
- persona-work:/work # workspace: traits, hooks, prompts, tasks, journal
- persona-data:/data # HOME: pi config, auth, sessions
- ${HOME}/.pi/agent/models.json:/config/models.json:ro
- ${HOME}/.pi/agent/settings.json:/config/settings.json:ro
- ${HOME}/.pi/agent/auth.json:/config/auth.json:ro
# api keys, the voice endpoint, the matrix bridge, any HMUX_CFG_* override; see
# persona.env.example in the repo. uncomment once the file exists.
#env_file: ./persona.env
volumes:
persona-work:
persona-data:then:
docker compose up -d
the same file, the same commands:
podman compose up -d
http://localhost:4280/
the hub root is the portal: a launcher listing every face persona serves, each a large tap target with a live online dot. everything reaches persona through that single port:
/admin-- the full chat ui: tools, thinking, permission prompts/chat-- a simplified chat: sessions, a transcript, a prompt box/omni-- voice and text/touch-- the ambient display/canvas-- the screen Per's browser is on, live, and yours to take the mouse on
opening a face FROM the portal starts a FRESH session. reaching the same page any other way -- a bookmark, a reload, the back button -- resumes the session you were last in. a face that is merely offline stays listed and greyed out; the voice face carries a machine protocol rather than a page, so the portal does not list it.
say hi in /admin and Per introduces itself. see chat for what to ask it.
persona TRUSTS the identity its front door reports, because a persona is a thing PEOPLE talk to and one that cannot tell them apart addresses everyone as its single human. hmux has no auth of its own, so on a directly-reachable hub those headers are just headers the caller chose. publish 4280 on localhost or a private network, or put an authenticating proxy in front of it. to turn the trust off instead, set in the container's environment:
HMUX_CFG_profile__persona__identity__trust_headers=false
the volumes hold everything persona has become, and none of it lives in the image:
docker pull docker.io/khimaros/persona:latest
docker rm -f persona && docker run -d ... # the same run command as above
under compose, docker compose pull && docker compose up -d (or the podman compose pair).
copy persona.env.example to persona.env and point the container at it
(--env-file persona.env, or env_file: in compose). without a checkout, fetch it:
curl -O https://raw.githubusercontent.com/khimaros/persona/master/persona.env.example. every
line in it is commented, so an unedited copy changes nothing. that file is everything the CONTAINER
reads: api keys, the matrix bridge, the voice endpoint, and any HMUX_CFG_* override of an
hmux/config.toml setting -- which reaches every scalar in that file, including tables it does
not mention.
for what an environment variable cannot spell, copy
hmux/config.local.toml.example to hmux/config.local.toml
(gitignored, seeded by make up, mounted read-only). it is merged over hmux/config.toml at
startup: tables merge, scalars and lists replace, and a face is matched by its kind. that is how
you add ONE permission rule without restating the policy -- and it is the only way to write a glob
key like "htru_*" at all, since a * is not legal in an environment variable name. between the
two, a deployment should not need to edit the checked-in config.
a spoke hmux/config.toml carries with enabled = false is configured and not running, which is
how federation ships: its substrates, tools setting, verify mode and permission rules are all
there, off. turning it on for one box is three lines in hmux/config.local.toml and restates none
of them:
[[profile.persona.spokes]]
kind = "federation"
enabled = trueup names every spoke it skipped at boot, so a face that is off says so rather than looking like
one that crashed.
what the runtime decides BEFORE the container exists -- published ports, where /work and
/data live, the uid -- cannot come from persona.env, because it is handed to the container
only after all of that is settled. those are flags on the run command, or the ports: /
volumes: / user: keys in compose.
the standalone recipes above use named volumes. the repo's own
docker-compose.yml bind-mounts host directories instead -- ./work and
./data beside the file, both gitignored -- so everything persona has become is readable and
backed up without going through the container, and survives a down -v. to move an existing
deployment across: down, copy each volume's _data (docker volume inspect <name> gives the
path) into ./work and ./data, then up.
as in step 1: the three ~/.pi/agent files are mounted read-only at
/config and copied into /data at boot, so providers, enabled models, defaults, and auth all
carry over. the provider host named in models.json must be reachable from the container.
mount an existing auth.json into /data/.local/share/opencode/, or run the provider's
device-code flow once against the persisted /data volume.
every ui hangs off the hub's single published port and is listed on the portal. each face can
also be published on a port of its own -- add -p 4282:4282 (admin), 4284 (omni), 4290
(chat) -- but there is rarely a reason to.
http://localhost:4280/admin -- the full renderer: tool calls, thinking, permission prompts.
/chat is the same conversation through a plainer surface.
http://localhost:4280/touch -- the ambient display. the face serving it also gives Per the
touch_* tools, so it furnishes the room it lives in, and what it adds survives a restart.
the omni voice + text ui is at http://localhost:4280/omni. speech runs on the VOICE FACE, not
in omni: set HMUX_VOICE_URL in persona.env (it falls back to OPENAI_BASE_URL), else the
face idles and omni is text-only. see persona.env.example.
an opencode tui attaches to the opencode face. that face has no portal mount -- opencode attach dials a base url, which a path-prefixed proxy has not been tested against -- so publish
its port (-p 4096:4096, or a ports: line in compose) and then:
opencode attach http://localhost:4096
use the Per agent for friendly conversation, browser use, and memory:
hi, my name is hank and i'm from cincinatti
modify your core operations to do something surreal whenever we talk
what was that link you sent me yesterday?
see also: browser use
use the Admin agent for meta-administration:
run all system updates and summarize all of the changelogs
configure the server to listen on port 8080
browser-use runs headless in the container. the agent runs unprivileged (the whole stack drops
to the hmux user; see HMUX.md), so chrome keeps its sandbox while browsing the
open web:
summarize the top 5 stories on hacker news
for headed, collaborative browsing the container needs your host X11 socket and $DISPLAY
(-v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY), and under rootless podman it must run as your
own uid so a sandboxed chrome can reach that socket (--user 1000:1000 --userns=keep-id:uid=1000,gid=1000). the checked-in docker-compose.yml wires all of it up;
with it running, make browser-head grants local X access and opens a real chrome window on
your desktop -- the same instance the agent drives, so you can watch, or take over to solve a
captcha. it blocks until you close the window; make browser-stop closes the session from
another shell.
both halves of the browser image phone home by default, and the image turns both off. browser-use
would otherwise send events to posthog, sync runs to api.browser-use.com and ping pypi on every
run; chrome would send UMA, fetch a variations seed, beacon failed requests, look urls up against
safebrowsing, query autofill and translate, and run the privacy sandbox. the settings ship in the
image itself (env vars plus a chrome managed policy at /etc/opt/chrome/policies/managed), so a
bare docker run on the base gets them too, not just this compose file.
to turn any of it back on for a debugging session, set the variable in persona.env
(ANONYMIZED_TELEMETRY, BROWSER_USE_CLOUD_SYNC, BROWSER_USE_VERSION_CHECK); the chrome half
is policy and needs a rebuild. python3 tests/telemetry_test.py checks all of it against the
built image, including the flags on a chrome it really starts.
you do not need any of that to watch. the container runs its own display (an Xvfb the
entrypoint starts), so chrome is headed whether or not you have a desktop, and /canvas serves
that screen live -- click, type and scroll in the page, from a phone if you like. it is a general
canvas rather than a browser viewer: anything on that display is watchable, the browser being the
first thing that is.
and Per can ask for you. when it hits a captcha, a login it has no credentials for, or
anything else it is stuck at rather than stuck about, it calls canvas_request_input and STOPS --
the tool does not return until you answer. the admin console opens the screen in a modal, you do what is
needed, and press i'm done (or can't do this, which it is also told). closing the window does
not answer it; the request stays open until you do, or until it times out and Per is told nobody
came.
that display has a window manager on it, so it behaves like a desk rather than a poster: windows have titlebars you can drag, borders you can resize, and buttons to maximise or close. the viewer's toolbar also carries standard sizes -- phone, tablet, laptop, desktop -- and fit to me, which asks the browser to be the size of the space you are looking at. these resize the real window, so the page lays itself out at that width with chrome's own furniture around it, which is what a screenshot cropped to a phone shape cannot give you. (a phone-shaped window is still not a phone: touch, the pixel ratio and the user agent all still say desktop.) one display, one window -- so if two people are watching, the last size asked for is the one everybody sees.
PERSONA_DISPLAY moves the container's display number if :88 collides with something on your
host -- /tmp/.X11-unix is shared with it, so the numbers are one namespace. a forwarded
$DISPLAY still wins, and /canvas then shows that display instead (and no window manager is
started there -- your desktop already has one). PERSONA_WM=none goes back to a bare display.
docker logs -f persona
or exec a shell: docker exec -it persona bash.
docker start persona # start
docker stop persona # stop
docker restart persona # restart
docker rm -f persona # remove the container (the volumes stay)
under compose: up -d, stop, restart, down (add -v only to destroy the volumes with
it). from a checkout the Makefile wraps the same lifecycle -- see below.
the repo is only needed to BUILD persona, to run the evals, or to use the make targets. clone
it, then:
make up # start (detached); seeds persona.env and hmux/config.local.toml from the examples
make down # stop and remove
make restart # restart the container
make logs # follow the logs
make admin # open the ui
make browser-head # open a headed chrome on your desktop (shared with the agent)
make browser-stop # close the shared browser session
make drives docker-compose.yml, which carries the same shape as the
compose file above plus the X11 forwarding, the config mount, and comments on every knob. it
defaults to podman-compose, which is what a deployment runs; use make COMPOSE="docker compose" ... for docker. that file is written for rootless podman -- on docker, delete the
userns_mode line, since keep-id is a podman-only knob (docker maps the container user to
your host uid directly).
make up also passes the uid mapping as podman run args:
--podman-run-args=--user=1000:1000 --podman-run-args=--userns=keep-id:uid=1000,gid=1000
this is not optional, and it is why make up is not a bare up -d. podman-compose 1.0.3
(bookworm) ignores the user: and userns_mode: keys in the compose file, so with that runtime
the mapping arrives here or not at all -- and without it the container starts as root, where the
base image chowns the bind-mounted ./work and ./data to a subuid your host user cannot read.
The container refuses to boot in that state rather than let it happen, and prints the invocation
to use. Set PERSONA_UID/PERSONA_GID if your host uid is not 1000, or COMPOSE_RUN_ARGS= to
empty when using a runtime that reads the mapping from the file.
to run the PUBLISHED image from the checkout rather than building one:
docker compose pull && docker compose up -d
persona's image mixes two others: khimaros/browser-use (built here, from images/browser-use)
as the base, and the payload of khimaros/hmux copied in on top. get the hmux image first --
pull it, or build it from the hmux repo
(cd ../hmux && make image) -- then:
make build
make up
the full build above is 15-20 minutes, most of it recompiling from source what your machine has already compiled. when you just need to try a change someone has handed you, there is a second build that lays the host's existing build output over the last full image instead:
cd ../hmux && make rust && make image-dev # 18s -> khimaros/hmux:dev
cd ../persona && make image-dev # 108s -> khimaros/persona:dev
PERSONA_IMAGE=khimaros/persona:dev make down up
to go back to the released image, run make down && make up with PERSONA_IMAGE unset. it defaults
to khimaros/persona:latest, and the two are separate images -- a dev build cannot take the release
tag.
make down up and NOT make up: podman-compose does not recreate a container when only the image
tag changed, so a bare make up prints the new image id, exits 0, and leaves the old container
running. (down is safe here -- it has no -v, and /work and /data are host directories.)
three things this build does deliberately:
- it refuses if any artifact it needs is missing, and names each one, rather than silently keeping the older copy from the base image. if it stops you, build what it asks for and re-run.
- the container says what it is on every start, so
podman logscannot mislead you:hmux: DEV BUILD: THIS IS A DEV OVERLAY, NOT A RELEASE. make publishrefuses a dev image, in this repo and in hmux.
it carries debug binaries, because that is what a development machine has warm. that is fine for
"does the fix work" and wrong for anything timed -- cut a real make image before measuring
latency.
run the persona eval suite against a throwaway container (a native client to the hmux hub):
make eval # full suite
make eval FILTER=TestTrait # only matching tests (pytest -k)
make eval REPEAT=5 # repeat and aggregate flakiness
make eval MODEL=kairos/ornith-1.0-35b:Q8_0 # pin a model (provider/id from models.json)
make eval REASONING=high # thinking level: off (default), low, medium, high
make eval-bridge # matrix-bridge tests against a throwaway fake homeserver
make eval-browser # browser-use tests (headless real browsing)
each run gets its own container, its own random host port and fresh volumes, so it neither
leaks state between runs nor disturbs a persona you already have up. it runs heartbeat-free
and drives evals/persona_eval.py as a native hub client over the :4280/ws api. MODEL
pins the model under test (a provider/id from your models.json); REASONING sets the
per-session thinking level -- off (default), low, medium, or high. off is the
calibrated default: reasoning tends to make a model answer from its own reasoning rather than
follow the injected persona instructions. MODEL/REASONING apply to make eval-bridge and
make eval-browser too.