kirby-api
FastAPI mono backend for Kirby, a HERO System VTT platform. Runs on Hetzner as a systemd service behind Cloudflare Tunnel at kirby-api.productbinder.net, port 9013 internally. Serves the SvelteKit frontend at kirby.productbinder.io (see ../kirby-app/) plus the forked Foundry module that talks to it.
This is the backend half of a two-service stack. The frontend lives at ../kirby-app/. The full design is in ../kirby/docs/superpowers/specs/2026-04-07-kirby-design.md.
What's here (minimum viable skeleton)
kirby/— the Python package (FastAPI app, domains, engines)main.py— FastAPI application entry pointsettings.py— pydantic-settings config loaded from envauth/— Mother Box: Discord OAuth, JWT issuance, JWKS, middlewaretenants/— multi-tenant-by-GM scopingcharacters/— character CRUD, feature-flag-gated editorservices/entity_service.py— HDC import with byte-preserving round-trip; substitutes no value, refuses instead
campaigns/— campaign managementlibrary/— The Source: bestiary, scenarios, loremedia/— Boom Tube: R2-backed asset storagetemplates/— HDT template management (Main6E + 16 others)engines/— pluggable engine modulescost/— cost engine (absorbed from kirby-cost)
combat/— Krackle: a HARNESS over thekirby-combatengine (see below)models.py— four SQLAlchemy tables (combat_session, combat_participant, combat_event, combat_narration)schemas.py— Pydantic request/response shapesharness.py— rehydrate-by-replay, one Phase per step, pinned builds, derived dicecreate.py— seating a fight: rows, pinned builds, the engine's opening. Commits nothingchoosers.py— routes each fighter's Phase to its seat (player submission or model)events.py— engine event ↔combat_eventrowroutes.py— REST + WebSocket under/api/combat/*random_fight/— matchmaking an N-way free-for-all, and the worker that runs itservices/publish.py+services/subscriber.py— Redis pub/sub fan-out
admin/— operator-only routesshared/— DB session, Redis client, tenant-query helpers, errors
alembic/— migration filesdata/— engine data files (template_6e.json, language_chart.json, etc.)tests/— pytest suite (unit, integration, fixtures, combat/)
Combat module (Krackle) — a harness, not an engine
kirby-api resolves no rule. kirby-combat decides who acts, what the menu
is, whether the Segment is spent and whether the fight is over;
kirby-ai chooses and narrates. What this module owns is HTTP,
persistence, tenancy, fan-out and matchmaking. There is no api-side menu,
no api-side mapping of an engine word onto a local one, and no fallback:
run_phase(..., on_unresolvable="raise") always, so a kind the engine
cannot execute stops the fight instead of quietly becoming a rule not
applied.
The design is
../kirby/docs/superpowers/specs/2026-09-17-api-is-a-harness-design.md.
The surface
| Route | What it does |
|---|---|
POST /api/combat/sessions |
Seat a fight: participants, template_id, seat, optional narration_seat, dice_seed, scene_seed. GM only. |
GET /api/combat/sessions/{id} |
The fight's identity plus the state its log folds to and its scene (the engine's Scene — walls, surfaces, hazards, constructs — None when scene_seed is None). |
GET .../situation |
The Phase the engine would offer next, without running it. |
POST .../step |
Run ONE Phase with the fight's own seats. GM only. |
POST .../choose |
Declare for the combatant whose Phase it is, then run that Phase. |
POST .../run |
Repeated step to a decision or a Turn cap. Its events/narration are ONE PAGE (the same 500 cap GET .../events has) with truncated and last_sequence to page from. |
GET .../events |
The record, paged (since=, limit). |
GET .../state?at= |
The fight as it stood at a sequence — the ENGINE's state_view(), forwarded unchanged: vitals, position and facing, conditions, who each man can perceive, and whose Phase is next. A sequence past the record is a 400, never a silent clamp. |
GET .../narration |
The narration table: at most one line per Phase, keyed by the engine's sequence, each carrying speaker_id — the Phase actor's combatant id, in the same vocabulary as combat_participant's combatant id — beside the seat that spoke it. |
WS .../live |
Live events; multi-worker fan-out over the combat:{id} Redis channel. |
GET /api/combat/version |
The engine and seat-package versions this deployment runs. A client that generated its event types from one engine's schema compares and refuses; this route holds no opinion about the comparison. |
POST /api/combat/random-fight |
Matchmake an N-way free-for-all and queue the loop. GM only. |
POST /api/combat/random-fight needs KIRBY_CORPUS_TENANT_ID set to
the tenant the villain books were imported into. The matchmaker draws
from that tenant and the caller's own, and from nowhere else: the
production corpus lives in the kirby-api database, character_def.source
is set freely by the caller on create, so source IN ('CV1','CV2','CV3', 'OSE') was never a fence. Unset, the route refuses with 409 rather than
opening the pool to every tenant.
step, choose and run report two failures rather than hiding them:
narration_error (the narration seat raised; the Phase stands) and
publish_error (the fan-out raised; the Phase stands and is on disk).
Neither is ever a placeholder — "the seat failed" and "he said nothing"
stay different facts.
What playback promises
The record holds the build the fight was fought with. Each
participant's costed build doc is pinned into
combat_participant.build_jsonb when the fight is created, so every
rehydration rebuilds the starting men from the sheet they started with —
an XP spend, a variant or a re-import afterwards cannot rewrite that
fight's history.
The api projects nothing here. state on state?at=, on
GET /sessions/{id} and on step / choose / run is the engine's own
kirby_combat.state_view(), serialised by the engine's own to_dict, so
what a client reads is what the engine folded and not a second reading of
the log made here.
Rehydration is replay and only replay: the starting combatants from those
pinned builds, the arena from scene_seed (the engine generates it), then
the engine's own apply_event over every persisted row in order. There is
no snapshot and no mutable per-fight state column to drift from the
record.
Two honest limits:
- Rolls are not logged. Each step's roller is derived as
blake2b(f"{dice_seed}:{next_sequence}"), so the dice a step drew are reproducible from the record — but no roll is stored, and a fight whosedice_seedis NULL (the row saying RANDOM) cannot be re-derived at all. State replays; the rolls behind it are re-derived, not read back. - A library release can re-cost a finished fight's numbers. The build
doc in the record is fixed, but a cost read off it comes from whichever
kirby-costis installed. The fight's events do not change; a total displayed beside them can.
Install and run
kirby-api is published to public PyPI. It runs wherever Python 3.11+ and
Postgres run; the Hetzner box is one consumer of the published version, not
the place the software lives.
pip install kirby-api
kirby-api migrate # alembic upgrade head, against the packaged migrations
kirby-api serve # uvicorn on kirby.main:app
serve takes --host, --port, --workers and --log-level. The
migrations ship inside the wheel, so migrate needs no checkout.
What it needs in the environment
Nothing has a production default, and a missing value is refused rather than guessed at:
| Variable | What it is |
|---|---|
DATABASE_URL |
Postgres, with the vector extension available (pgvector). Required — both commands refuse and name it if it is unset. |
REDIS_URL |
Redis, for cross-worker WebSocket fan-out of live combat. |
KIRBY_MEDIALIB_API_URL |
The medialib instance every generation call goes through. kirby-api holds no model table and no provider key. |
TANNHAUSER_ISSUER / TANNHAUSER_JWKS_URL / TANNHAUSER_AUDIENCE |
The identity provider. kirby-api is a relying party: it validates JWTs, it never issues them. |
KIRBY_CORPUS_TENANT_ID |
The tenant holding the canonical character corpus the random fight matchmakes from. Unset means "the caller's own tenant only" — a deployment that has not said where its corpus lives gets a refusal, not somebody else's characters. |
KIRBY_API_URL / KIRBY_APP_URL / CORS_ORIGINS |
Self URL, frontend URL, and the origins allowed to call the API. |
.env.example carries the full list. No secret value belongs in any of the
files in this repository.
A note on the test suite
Part of the suite reads Hero Games licensed material — the character corpus
and the HERO Designer .hdt template, addressed by KIRBY_CORPUS_URL,
OSE_HDC_DIR, CHAMPIONS_HDC_DIR and KIRBY_COST_HDT. That material is
never vendored, and those tests skip themselves wherever it is absent. They
gate the founder's own deployment; they do not gate a release of the
software, and the release workflow runs without any of it.
Running locally
cd kirby-api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pip install -e ../kirby-cost # cost engine, see below
cp .env.example .env
# edit .env with your local Postgres credentials + Discord app credentials
alembic upgrade head
uvicorn kirby.main:app --reload --port 9013
Then curl http://127.0.0.1:9013/health should return {"status": "ok"}.
Cost engine setup
The cost engine (HDC parsing + HERO 6E cost math) lives in the sibling
kirby-cost repo and is consumed via the
kirby.engines.cost facade (see kirby/engines/cost/__init__.py).
Install it as an editable local package:
.venv/bin/pip install -e ../kirby-cost
This is a sibling-repo editable install — same pattern Codex uses for
PyTorch — so it's not declared in pyproject.toml. The facade re-exports
HDCLoader, LoadedHero, Modifier, Adder, Multipower,
VariablePowerPool, and ElementalControl, plus load_hdc(bytes) and
load_hdc_from_path(path) for the REST upload path.
Running on Hetzner (production)
Via kirby-api.service (systemd), git pull + restart. See the design spec for full deploy notes. Mirrors the medialib-api operational shape.
Environment variables
See .env.example. Key ones:
DATABASE_URL— Postgres connection string (port 15432 on Hetzner, 5432 locally usually)REDIS_URL— Redis connection string (port 16379 on Hetzner)DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET— Discord OAuth app credentialsJWT_PRIVATE_KEY_PATH/JWT_PUBLIC_KEY_PATH— RSA keypair for RS256 JWT signingKIRBY_API_URL— self-URL for OIDC discovery (https://kirby-api.productbinder.netin prod)KIRBY_APP_URL— frontend URL for OAuth callback redirects (https://kirby.productbinder.ioin prod)
Engine provenance (short version)
- Cost engine — Python port of HERO Designer's math, built and validated against the Java HD oracle (655 fixtures, 28,593 / 28,593 cost calculations matching). Fully covered by PeterB's $50 HD source code license purchased from herogames.com on 2026-04-07 (Simon's license, customer #29 ever).
- Combat engine — Pure-Python port of
dmdorman/hero6e-foundryvtt(MIT). Lives in the separatekirby-combatpackage (git@github.com:pdbethke/kirby-combat.git), consumed as a library. Attribution to Dorman inkirby-combat/LICENSEand README. Phase 1 (to-hit, damage, defense, knockback, status, action pipeline) shipped 2026-04-16 with 98% test coverage. This is the live combat engine — it is NOT conditional on any NDA. An earlier plan to port from David Tannen's HCM under NDA was abandoned when David became unresponsive; PeterB's written commitments to David (no HCM source, no decompiled HCM reliance, Foundry-talking-backend architecture) are all honored by the Dorman-based approach.
What publishes, and what never does
The software publishes: kirby-api goes to public PyPI alongside
kirby-combat, kirby-cost, kirby-dice and kirby-ai (founder decision,
2026-09-18). It is a product, not a box.
The licensed material does not, ever: no Hero Games character corpus, no
.hdc files, no HERO Designer .hdt templates, no HD source. None of it is
in this repository, none of it is in the wheel, and
tests/test_wheel_contents.py fails the build if anything secret-shaped
turns up in package data. See project_hd_source_license.md,
project_david_tannen_emails.md and project_hero_games_org_chart.md in
Claude's project memory for the full licensing posture and the commitments
PeterB has made.
Release files for kirby-api 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kirby_api-0.1.0.tar.gz | 403.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kirby_api-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 910.2 kB
Release files / kirby_api-0.1.0.tar.gz
| Download URL | kirby_api-0.1.0.tar.gz |
|---|---|
| Size | 403.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
37cc5f2eb00da077946f3f69a44b656d308aa37a54f4d04e86051b449e6a6ff6
|
|
BLAKE2b-256 checksum How to use checksums |
0900239b6139344a97c16a6a7f9a9bf353e36a253dbddaef125f3f781855defd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / kirby_api-0.1.0-py3-none-any.whl
| Download URL | kirby_api-0.1.0-py3-none-any.whl |
|---|---|
| Size | 507.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
eab0d55a81fc51f50d0b7b0be552ce402db478905a60b1540cde78c4d42c176f
|
|
BLAKE2b-256 checksum How to use checksums |
ba9fcff165e5a98ed26ebcd998710ad75b342e2db48a4f9f09ab1854816c8f66
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|