On this page

Reference

Docs

Everything EvoPet fixes, everything a pet creator chooses, and the parts that are still being built. Where a number appears here it was computed from the code that produces it, and where something does not work it is listed as a defect.

Quick start

The short version, for a machine that already runs a Hermes agent. It installs the pet for one profile and selects it.

git clone <the pet tree>      # from a checkout of the pet tree
cd <the pet tree>
./hermes/install-hermes.sh --line toast --machine aurora
hermes plugins enable tamahermes
hermes pets select tamahermes
hermes pets doctor        # should report ✓ ready

hermes pets doctor should report the pet ready. The long version — every profile at once, the installer flags, and floating the pet on the desktop — is in Getting started below.

Getting started

Install the pet, enable the plugin, and put it on the desktop.

Install the pet

EvoPet is the pet and the desktop shell, and they install separately. You can run the pet alone, in the terminal, with no desktop app at all. From a checkout of the pet tree:

git clone <the pet tree>      # from a checkout of the pet tree
cd <the pet tree>
./hermes/install-hermes.sh --line toast --machine aurora
Before you start

EvoPet's own repository is public as of 11 September 2026, and the pet tree is a separate tree, so these instructions assume a checkout on your machine. The pet needs Python and nothing else — its only dependency is Pillow, which already ships with Hermes.

That one command installs the package, builds the pet into your Hermes home, copies the plugin, and selects the pet.

Enable the plugin

The plugin is how growth events reach the ledger. It is a native Hermes plugin, so it runs in process, nothing needs allowlisting, and hook callbacks only enqueue a payload — the atlas rebuild is coalesced, so a burst of tool calls triggers one rebuild rather than forty. A failed rebuild is logged and dropped, because a mascot must never break an agent turn.

The alternative is shell hooks: put the block from hermes/hooks.snippet.yaml into <HERMES_HOME>/config.yaml, replacing __REPO__ with your checkout path, then allowlist each entry with hermes hooks list. Pick one. Wiring both feeds the same ledger twice.

pets select

Selecting the pet is what makes it the active one for that profile, and it happens once per install:

hermes plugins enable tamahermes
hermes pets select tamahermes
hermes pets doctor        # should report ✓ ready

pets doctor

hermes pets doctor is the check, and it should report the pet ready. If it does not, start with the two things that go wrong most: the plugin was copied but never enabled, or the install was pointed at a home whose config.yaml still uses the legacy bare-list plugins: form.

Per-profile install

Hermes homes are profile-scoped: the pets directory is <HERMES_HOME>/pets, and HERMES_HOME is <home>/.hermes/profiles/<name> inside a named profile (unset — that is, ~/.hermes — for the default profile). So each profile owns its own pet and its own ledger, and you install once per profile you actually use.

Pointing an install at a non-live home, which is what tests and staging do, installs the pet but deliberately does not run hermes pets select, so it cannot change the config of the profile you are actually running.

install-all-profiles.sh

One command covers the default profile and every named profile on the machine. It also takes --only name1,name2 and --keep-selection (install the pet without changing which pet is active).

# every Hermes profile on this machine: the default one and ~/.hermes/profiles/*
./hermes/install-all-profiles.sh

# or one at a time
HERMES_HOME=~/.hermes/profiles/you ./hermes/install-hermes.sh
HERMES_HOME=~/.hermes                 ./hermes/install-hermes.sh

Installer flags

FlagEffect
--line LINE_ID which companion line to install. Default: toast.
--machine aurora|pulse which outer shell. Default: aurora.
--form FORM_ID install one exact form, grown or not.
--display-name NAME the name shown for the pet.
--reset start over from a fresh egg.
--shell-hooks print the config.yaml hook block instead of copying the plugin.
--petdex also mirror the pet into the desktop home so it floats (adds it to the rotation).
--petdex-activate with --petdex, also make it the active desktop pet.
--petdex-home PATH the desktop home to use. Default: ~/.petdex.

Float it on the desktop

The pet renders in the terminal by default. Mirroring it into the desktop shell's pet directory makes it float there too, off the same ledger:

./hermes/install-hermes.sh --petdex-activate   # close the desktop app first
./hermes/install-all-profiles.sh --petdex-activate
Close the desktop app first

The app rewrites its settings file from memory when it quits, so an activate pass against a running app is clobbered. Only --petdex-activate touches which pet is on screen, and it preserves every other key of that file, including their order.

The mirror is a copy, not a second pet. Once the opt-in marker is recorded at <HERMES_HOME>/tamahermes/petdex-home, every growth refresh re-mirrors the sheet, so the floating pet evolves with the terminal one instead of freezing at whatever it looked like on install day.

One honest caveat: the desktop pet is machine-wide while growth ledgers are per-profile, so an unmanaged mirror shows whichever profile last changed. Installing from every profile is what keeps it current from all of them, and the combined ledger is what makes the ambiguity go away.

Environment

None of these are needed for a normal install. They exist so tests, staging and unusual layouts can point the same code somewhere else.

VariablePurpose
HERMES_HOME the target Hermes home. Inside a named profile it is <home>/.hermes/profiles/<name>; unset, it is ~/.hermes.
TAMAHERMES_HOME overrides HERMES_HOME for the hook script and the plugin.
TAMAHERMES_PETDEX_HOME the desktop home to mirror into. Unset means no desktop mirror.
TAMAHERMES_REPO_ROOT lets the plugin and the hook script import the package without installing it.
TAMAHERMES_PY the interpreter the shell-hook wrapper runs.
TAMAHERMES_SYNC=1 run the plugin inline instead of on its worker thread (tests).
TAMAHERMES_LINE / TAMAHERMES_MACHINE pin the companion line and the shell.
TAMAHERMES_CATALOG_DIR a custom rendered catalog directory.

What is kept, and where

Nothing here is uploaded by installing. Growth is local until you publish.
PathWhat it is
<HERMES_HOME>/pets/tamahermes/pet.json the manifest Hermes reads, including the evolution gates
<HERMES_HOME>/pets/tamahermes/spritesheet-<hash>.webp the compiled atlas, 8 columns by 9 rows of 192×208 cells
<HERMES_HOME>/tamahermes/state.json the profile’s growth ledger
<HERMES_HOME>/tamahermes/hermes-hook-state.json per-turn bookkeeping (turn ids only)
<HERMES_HOME>/plugins/tamahermes/ the native plugin the installer copies
~/.evopet/state.json the combined ledger: one pet’s worth of XP from every source
~/.evopet/settings.json the auto-upload switch. See the library docs

Only numbers are stored: XP, counters, stats and traits — never your prompts, never tool output, never a file path. The combined ledger's restriction is enforced by its own test rather than by convention.

The desktop shell

The shell is the app that floats the pet and taps every agent's hook payload, so Claude Code, Codex, opencode and Gemini CLI can feed the same creature. It is a Zig build with a pinned SDK, and today it is a local build: there is no notarized download, and a bundle built on one Mac is rejected by Gatekeeper on any other.

Because of that, the honest install path for the shell right now is to build it from the app tree on the machine that will run it. It installs as its own app, leaving the stock one in place so you can revert by quitting and reopening the other.

The shell half descends from Petdex, the desktop app shell lineage. An earlier repository published as ahrazzle/petdex is that lineage's older home; this site links the lineage as Petdex, and the older mirror is named here as prose rather than offered as a second button.

If hooks look dead

The shell exits 0 by design when its hook server fails to bind — for example because another petdex is already running — and then keeps running with every hook silently doing nothing. Check the hook server's /health first.

Source. README.hermes.md, hermes/install-hermes.sh; tamahermes/paths.py and tamahermes/pet_compiler.py for what an install writes.

Mechanics

The loop, the ladder, the gates, and the gauges that are watched.

The loop

  1. An agent does something; Hermes fires a hook event (prompt_sent, task_success, …).
  2. The event is applied to that profile's ledger as XP plus stat changes, at the turn boundary.
  3. The compiler redraws the pet's atlas and installs it. Rebuilds are coalesced, so a burst of tool calls is one redraw.
  4. Every surface renders that one sheet: the terminal pet and the desktop mirror.

XP is awarded at turn boundaries, never per tool call. A turn in Codex has to be worth a turn in Hermes; per-tool accounting would inflate the pet twenty to forty times over against the ladder.

XP events

One table in the code — state.EVENT_DELTAS — is every XP value and every stat change.
event XP fires on
task_success 14 a turn ends successfully, once per turn
recovery 6 a success after a failure in the same turn
task_failure 5 a turn fails
review_opened 5 a review or critique is opened
prompt_sent 4 every prompt you send
care 3 you tend the pet yourself
session_start 2 an agent session begins
rest, token_usage, idle_minute, drag 0 these move stats and energy only

A good turn is about 18 XP — a prompt and a success — and a failing turn about 9 to 11.

The ladder

Every pet in every package climbs the same ladder, so a level means the same amount of work in every pet. It is fixed by EvoPet, cheap at the start and long at the end:

xp_for_level(L) = round(100_000 × ((L − 1) ÷ 98)²), that is CAP_XP = 100,000, MAX_LEVEL = 99, EXPONENT = 2.0.

Rendered from the formula above, not retyped.
level cumulative XP what this level costs
1 0
2 10 10
20 3,759 385
50 25,000 1,010
70 49,573 1,426
99 100,000 2,030

XP is cumulative and never decreases, so a level once reached is never lost. Half the cap, 50,000 XP, lands inside level 70, and the climb from level 50 to level 99 costs 75,000 XP.

The bar fills against the level, not the form. level_progress(xp) reports the level and the position between its floor (xp_for_level(L)) and its ceiling (xp_for_level(L + 1)), so the bar fills about a hundred times in a pet's life and resets at every level-up — but the sprite changes only when a gate is crossed, because a gate is the only thing that changes the form. At level 99 the ceiling is None, the bar reports 100%, and the drawn value snaps to 5% buckets.

Evolution gates

How many evolutions a pet has, and at which levels, is the creator's choice, declared in the package's own manifest:

"evopet": { "evolutionGates": [11, 23, 32, 45] }

A gate is a level the pet reaches: when cumulative XP first equals or passes xp_for_level(gate), the pet moves to the next form. There is always one more form than gate, so one to four gates means two to five forms. The default pet declares (11, 23, 32, 45), which on the fixed ladder is:

gate level cumulative XP from to XP this step costs
11 1,041 egg hatchling 1,041
23 5,040 hatchling child 3,999
32 10,006 child teen 4,966
45 20,158 teen adult 10,152

When a declaration takes effect

  1. Declare "evopet": { "evolutionGates": [11, 23, 32, 45] } in the pet.json the install will replace, or edit the installed one afterwards.
  2. Install. The installer absorbs the manifest it is about to replace into the ledger (pet_compiler.sync_ledger_gates), then builds, so the new pet.json carries the ledger's gates.
  3. The change takes effect on the next install, not on the next event. Nothing re-reads a manifest mid-run, and it is the ledger's own gate list — state.evolution_gates(state) — that maybe_evolve and stage_progress read.

A manifest that declares nothing leaves the ledger alone: silence is not a declaration.

What a bad gate list is told

levels.validate_gates refuses, and names the problem. These are its messages, verbatim:

  • evolution gate 120 is outside 1..99
  • evolution gates must strictly increase; 10 follows 30
  • a pet needs at least one evolution gate
  • at most 4 gates supported (5 forms); got 5

A block that is present but invalid is refused at install, and PetCompileError names the file: pet.json: evolution gates must strictly increase; 20 follows 40. A ledger with no evolutionGates key at all behaves exactly as it always has, on the default pet's gates — a growth event that raised would strand a user mid-run.

Two properties make a reached gate safe to rely on. The gate level becomes a floor in the ledger's threshold table, and XP only rises, so a pet cannot regress out of a form. And the terminal form is absent from that table, so the last form has nothing to grow into.

Forms and branches

Form names are positional and not the creator's to choose, taken from STAGE_ORDER = ("egg", "hatchling", "child", "teen", "adult"). Three gates give you egg, hatchling, child and teen — and your pet never reaches adult.

teen and adult also carry branch variants that the runtime picks from stats and traits rather than from a gate:

  • teenresilient (resilience ≥ focus + 3), restless (restlessness ≥ focus + 4), otherwise focused.
  • adult, checked in order — sleepy (energy < 28, or 120+ quiet minutes), worker (≥ 4 completed runs and focus ≥ resilience), resilient (resilience ≥ focus and ≥ 2 failures), quiet (restlessness ≤ 2 and 60+ quiet minutes), otherwise calm.

Branches are sticky: once chosen they persist, and they are restored after dormancy. A branch is a record of how you worked, not a choice you make. Form ids read <line>:<branch>, for example child:root or teen:focused.

Energy and dormancy

Energy is 0–100 and is the only stat that gates behaviour: it is the awake budget.

Dormancy is a condition, not a growth step — a hibernating pet still reports the progress it has genuinely made.
directionwhat moves it
drains session_start −1 · prompt_sent −1 · idle_minute −1 · task_failure −4
restores task_success +2 · recovery +1 · care +5 · rest +10 per 10 quiet minutes, to a maximum of 48 blocks (8 hours)
falls asleep energy ≤ 4, or health ≤ 12, or 240 idle minutes
wakes up energy ≥ 35 and health ≥ 35
bins ≤20 critical · ≤45 low · ≥80 full · otherwise ok

While asleep the pet draws flat sleeping artwork — a 24×11 source union against roughly 23×24 for every other form, so it looks vertically compressed and is not — and its bar shows real progress toward the form it will wake into rather than a pinned zero.

The five trackers

tracker derived from bins reads
energy stats.energy ≤20 critical, ≤45 low, ≥80 full, else ok Full is good. The only tracker that gates behaviour.
satiety totalTokens // 100, else a mix of prompt chars, tool-output chars, runs and token samples <35 hungry, ≥70 fed, else ok Full is good: how much work you have fed it.
health stats.health (−4 on failure, +2 recovery, +4 care, +3 rest) ≤35 weak, else ok Full is good.
bond stats.bond (+1 session, +2 success, +1 recovery, +3 care) <25 new, <65 warm, ≥65 attached Full is good.
mess mess + failedRuns×6 + careMistakes×4 + min(24, idleMinutes ÷ 10) + unresolved×3, where unresolved = workRuns − completedRuns − reviews <25 clean, <60 dusty, ≥60 messy Inverted: high is bad. See D1 and D2 below.

Also tracked but never shown: mood (which affects nothing) and the traits focus, resilience and restlessness, which decide the branch.

What the shipped status strip draws

The top row is silent unless something needs you. Energy is the one escalation: at 45 or below the bolt and its gauge appear, amber while energy is low and red once it is critical (20 or below). The alert glyph still shows a failure or a review, and the health warning shows while health is 35 or below. The bottom row is always drawn: satiety as the bowl, growth as the centre bar, bond as the heart — which stays hidden until the pet is past "new". A direction decision for a richer HUD is still open and is not shown here.

Known limitations

These are the defects the project has recorded in its own notes and, where named, in the code. None of them is documented as working anywhere on this site.

iddefect
D1 Mess can never fall. failedRuns and careMistakes are never decremented, and task_failure increments both, so mess_score gains +10 per failure monotonically and clamps at 100. Any used profile reads "messy" forever, which means a cleanliness gauge cannot work until failures decay.
D2 careMistakes is mis-modelled. It is meant to say "the owner neglected care", but it is incremented by agent turn failures — double-counting every failure in the mess score.
D3 Dormancy is easy to fall into. Energy drains on every prompt and session start and restores only over quiet time, so a long working session can pin the pet asleep.
D4 mood is dead weight. Tracked, sits near 100, displayed nowhere and affects nothing.
D5 level is computed and shown nowhere.
D6 opencode cannot earn turn XP. It tracks its phase internally but does not send it, so its payload carries no phase and its turns are indistinguishable from its tool calls.

Three further gaps are documented rather than fixed, and they matter if you are about to rely on them:

  • Nothing cross-checks your gates against your spritesheet when you declare them. validate_gates checks the list's shape only, so a line that lacks a form for a stage your gates imply fails later, in the catalogue lookup, when the pet grows into it — CatalogError: no form for line ….
  • A manifest's "forms" list is ignored: you cannot name your own forms.
  • Gates are levels only. There is no XP amount, no branch, no condition such as three failed runs; the teen and adult branches still come from stats.
Two notes in the project disagree about the numbers

levels-and-evolution.md — the pet's own contract — describes the 99-level ladder with gates at 11/23/32/45. An older mechanics write-up describes stage thresholds of 120/320/900/1800 and a level of 1 + xp // 25. Both cannot be true. This site publishes the gates model, which the shipped levels.py implements, and treats the older write-up as stale; that conflict is flagged rather than hidden, and regenerating the stale note is open work.

Source. tamahermes/levels.py, state.py, visual_state.py; MECHANICS.md. Sleep and wake thresholds, the tracker formulas and the bins were read out of tamahermes/state.py and tamahermes/visual_state.py; the defects are the project's own recorded list.

Library & publishing

The public index, what makes a pet capable, and how publishing works.

Not deployed

The library service is written, and passes its own end-to-end round trip. It is not deployed: the base URL the site calls is a placeholder until someone chooses the host, which also has to be chosen before the first publish because every URL in the index is absolute and issued from that value. Until then the library page says it cannot reach the library, which is the honest answer rather than an empty shelf.

Endpoints

One service, no database, no auth provider: Node and TypeScript, deployed as a single function, with one runtime dependency (the platform's own blob client). Public and owner-only bytes are separated deliberately — an approved pet's files are served to anyone, while a pending or rejected one's are served only to its owner or an operator, and an unauthorised request gets 404 rather than 403 so a slug cannot be used to probe for hidden pets.

method path auth purpose
GET /api/health liveness, storage mode, public base URL
GET /api/desktop/library bearer the app’s library response
POST /api/pets/publish bearer publish a package (multipart)
GET /api/manifest the public index, approved pets only
GET /api/pets/<slug> — / owner the index record
GET /api/pets/<slug>/pet.json — / owner the published pet.json, verbatim
GET /api/pets/<slug>/spritesheet.webp|png — / owner the published atlas
GET /api/pets/<slug>/thumbnail.webp|png — / owner the preview image
POST /api/pets/<slug>/catch bearer record “downloaded from the catalogue”
POST /api/admin/pets/<slug>/status operator set pending | approved | rejected
DELETE /api/pets/<slug> owner / operator remove a package, bytes included
GET /api/admin/deletions operator the deletion audit log

The index the library page reads is GET /api/manifest: public, unauthenticated, approved pets only, sorted by slug, and carrying generatedAt, total, capableTotal, assetBase and a row per pet with slug, displayName, thumbnailUrl, capable, evopet, petJsonUrl, spritesheetUrl, version and updatedAt.

Capability

Evolution capability is the evopet block, and nothing else. If a pet.json carries an object-valued evopet key the pet is capable and the parsed block is stored with the record and served in the index; otherwise it is a stock package and evopet is null. That is the desktop app's own marker, so the service refuses "evopet": "yes" rather than storing something the app would call incapable, and it asserts that its own flag agrees with the app's scanner before storing.

Declared evolutionGates are validated like levels.validate_gates: 14 gates, levels 1 to 99, strictly increasing. The index serves both the boolean and the block on purpose — the block is the whole truth, and the boolean is the convenience — so a client never has to re-derive the answer and never has to trust one that cannot be checked.

Package requirements

POST /api/pets/publish takes multipart/form-data only:

partrequirement
pet.json ≤ 16 KB — the window the app reads capability from
spritesheet.webp | spritesheet.png exactly 1536×1872: 8 × 9 cells of 192×208, the atlas the app's sprite loader and EvoPet's compiler share
thumbnail.png | thumbnail.webp optional; ≤ 256 KB and ≤ 512×512
public=true|false optional; the off switch described below

Validation happens before anything is stored: the manifest must parse, id must be a legal slug ([a-z0-9-], 164 bytes, no leading or trailing dash — it becomes a directory name under ~/.petdex/pets), displayName must fit 96 bytes, the sheet must be the documented atlas of the documented kind, and spritesheetPath must agree with the uploaded sheet.

The response is a record of what landed, not a promise:

{slug, status, url, capable, evopet, thumbnailUrl, version, replaced, notes}

Auth v1

One bearer key per user, minted by the operator CLI and stored hashed with scrypt. The secret is never written, never logged, and never returned again. Format evk1.<id>.<secret>: the id is the public lookup path, the secret is 32 random bytes. Issuing is a CLI rather than an HTTP endpoint on purpose — the first operator key would otherwise need a bootstrap secret in the environment that mints credentials, which is worse to hold than the storage token the CLI already needs.

npm run key:issue -- --email you@example.com --username you --display "owner key"
npm run key:revoke -- --id <id>

Publishing with a key:

curl -X POST "https://<host>/api/pets/publish" \
  -H "Authorization: Bearer <key>" \
  -F "pet.json=@pet.json" \
  -F "spritesheet.webp=@spritesheet.webp" \
  -F "thumbnail.png=@thumbnail.png" \
  -F "public=true"
The one thing that must not go wrong

The desktop app stores its session in the login keychain as a JSON blob, and refresh_token must be empty. On a 401 the app posts a non-empty refresh_token to a third party's token endpoint — so a library key sitting in the refresh slot is a credential leak to a service that has nothing to do with this library. Write the blob, which is what key:issue prints:

security add-generic-password -U -s dev.petdex.desktop-native -a oauth \
  -w '{"access_token":"<key>","refresh_token":""}'
PETDEX_LIBRARY_URL=https://<host>/api/desktop/library

With refresh_token empty, a 401 surfaces as a sync error and nothing leaves the machine.

Publication policy

Publishing is auto-upload: with no flag a package appears in the index immediately with the status the app already understands for a listed pet, approved. The off switch is public=false, which is the only thing that produces pending — and it is the only setting in this whole system that persists a choice, written to ~/.evopet/settings.json.

The auto-upload switch, as the app implements it.
itemvalue
file~/.evopet/settings.json
document{"autoUpload": true} or {"autoUpload": false}, written whole on every change
defaulton. Only an explicit false turns publishing off: a missing key, a missing file, an unreadable file or a value that is not a bare boolean all resolve to the default
where it is setthe desktop app: Settings → Pet library
copy in the app“A newly hatched pet is published to the EvoPet library so others can find it; turn this off to keep every pet local”

pending and rejected exist because the app already has both statuses, because the off switch has to leave an unlisted package somewhere honest, and because a maintainer needs to suppress a pet without destroying it. Nothing in the default path produces them, and the rejection path is a status change, not a queue.

from to by
approved publish, no flag (the default)
pending publish with public=false
pending approved owner re-publishing, or an operator
approved pending owner re-publishing with public=false, or an operator (unpublish)
pending / approved rejected operator
rejected pending / approved owner re-publishing, or an operator
any gone DELETE, owner (own pet) or operator (any pet)

The catalogue is stored one object per pet, not one catalogue document. A single document would be a read-modify-write with no compare-and-swap behind it, so two concurrent publishes would silently lose one of the two pets — exactly while the library is growing. Per-pet keys make a publish a single write to a key nobody else is writing; the price is that listing is a list plus N reads, which is fine at this scale and honest about it.

Deletion and takedown

DELETE /api/pets/<slug> removes the index record and every package object. No tombstone serves anything afterwards, and the slug becomes a 404. An owner can delete their own pet; an operator key can delete any pet, optionally with a reason, and that reason is kept for maintainer deletions only, in an operator-readable audit entry.

curl -X DELETE "https://<host>/api/pets/<slug>?reason=<complaint%20ref>" \
  -H "Authorization: Bearer <operator key>"
No monitored takedown address exists yet

A third-party complaint needs an address a rights holder can write to, and there is not one: the interim posture is that removal is a manual operator action. Any hosted public use should add a monitored address, a response-time commitment, and a repeat-infringer rule for publishing keys. This site states that plainly rather than implying a process that does not exist, and makes no claim about how fast a takedown would be answered.

What this site does not do

  • No authenticated flow of any kind. No accounts, no OAuth, no keys, no uploads. The library page reads one public endpoint and nothing else.
  • No hosted claim. The service's deploy steps live in its own repository; this site neither performs nor asserts a deployment, and it does not choose the public base URL.
  • No install path that does not exist. The app reads its catalogue manifest from a compiled constant and trusts asset URLs under its own host, so petdex://<slug> installs still resolve to petdex.dev. Only the library URL is overridable. A link here would imply otherwise.

Source. evopet-library/README.md, src/manifest.ts; evopet_settings.zig and settings_view.zig for the auto-upload switch, quoted from its own copy.

Reference

Constants, functions and the files they live in.

Constants

All of these live in tamahermes/levels.py. Change them there and nowhere else.

namevalue
MAX_LEVEL 99
CAP_XP 100,000
EXPONENT 2
MANIFEST_KEY "evopet"
STAGE_ORDER ("egg", "hatchling", "child", "teen", "adult")
DEFAULT_EVOLUTION_GATES (11, 23, 32, 45)

Function list

In the order you will want them:

functionwhat it answers
xp_for_level(L) cumulative XP needed to reach a level; level 1 is 0, level 99 is the cap
level_for_xp(xp) the level a pet with that much cumulative XP is in (1–99)
level_progress(xp) the level band the bar fills against: floor, ceiling, percent
validate_gates(gates) checks a creator’s gate list and raises ValueError, naming the problem
gates_from_manifest(manifest) the creator’s gates, or the default pet’s when it declares none
forms_for_gates(gates) the positional form names implied by a gate list
thresholds_for_gates(gates) {form: the XP it begins at}
ledger_thresholds(gates) {stage: the XP that ends it}, with the terminal form absent
stage_for_xp(xp, gates) the form a pet is in at that XP
gate_report(gates) the creator-facing gate table: level, XP, from, to
curve_report(levels) cumulative XP at chosen levels, plus what each level costs

Code map

filewhat lives there
tamahermes/levels.py the whole contract above: the curve, the gates, the validation, and the reports
tamahermes/state.py EVENT_DELTAS — one table holding every XP value and every stat change — plus STAGE_THRESHOLDS, maybe_evolve, the teen and adult branch choosers, and passive rest
tamahermes/visual_state.py the bin functions, mess_score, satiety_score, alert_bin, and derive_visual_state
tamahermes/pet_compiler.py draws the atlas: the floating layout, the growth bar, the status strip, the icons
tamahermes/hermes_events.py maps a Hermes hook event onto a pet event
tamahermes/evopet_drain.py the cross-agent drain: absorbs per-profile XP into the combined ledger, consumes spooled foreign turns at turn boundaries, and prunes what it absorbed
hermes/install-hermes.sh installs the pet and plugin for one Hermes home
hermes/install-all-profiles.sh the same, for the default profile and every named profile

The contract is covered by the pet's own suite: the ladder, the gates and the reports are exercised from the same formula this site renders, so a constant moved in one place and not the other is a failing test rather than a wrong page.

Source. tamahermes/levels.py and the module list it names. Constants read from the module, not from prose.

A note on this documentation

Some of the project's own notes have fallen behind the code, which is normal and not worth hiding. The mechanics write-up still describes an older ladder and an older notion of when a pet evolves; where a note and the source disagree, the source wins, and Known limitations names the specific differences.

There is no documentation search at v1, deliberately: the site is four routes with anchors inside them, and a search box over this much text would be decoration rather than a tool.