Skip to main content

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 point
    • settings.py — pydantic-settings config loaded from env
    • auth/ — Mother Box: Discord OAuth, JWT issuance, JWKS, middleware
    • tenants/ — multi-tenant-by-GM scoping
    • characters/ — character CRUD, feature-flag-gated editor
      • services/entity_service.py — HDC import with byte-preserving round-trip; substitutes no value, refuses instead
    • campaigns/ — campaign management
    • library/ — The Source: bestiary, scenarios, lore
    • media/ — Boom Tube: R2-backed asset storage
    • templates/ — HDT template management (Main6E + 16 others)
    • engines/ — pluggable engine modules
      • cost/ — cost engine (absorbed from kirby-cost)
    • combat/Krackle: a HARNESS over the kirby-combat engine (see below)
      • models.py — four SQLAlchemy tables (combat_session, combat_participant, combat_event, combat_narration)
      • schemas.py — Pydantic request/response shapes
      • harness.py — rehydrate-by-replay, one Phase per step, pinned builds, derived dice
      • create.py — seating a fight: rows, pinned builds, the engine's opening. Commits nothing
      • choosers.py — routes each fighter's Phase to its seat (player submission or model)
      • events.py — engine event ↔ combat_event row
      • routes.py — REST + WebSocket under /api/combat/*
      • random_fight/ — matchmaking an N-way free-for-all, and the worker that runs it
      • services/publish.py + services/subscriber.py — Redis pub/sub fan-out
    • admin/ — operator-only routes
    • shared/ — DB session, Redis client, tenant-query helpers, errors
  • alembic/ — migration files
  • data/ — 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 whose dice_seed is 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-cost is 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 credentials
  • JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH — RSA keypair for RS256 JWT signing
  • KIRBY_API_URL — self-URL for OIDC discovery (https://kirby-api.productbinder.net in prod)
  • KIRBY_APP_URL — frontend URL for OAuth callback redirects (https://kirby.productbinder.io in 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 separate kirby-combat package (git@github.com:pdbethke/kirby-combat.git), consumed as a library. Attribution to Dorman in kirby-combat/LICENSE and 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)

Source distribution for kirby-api 0.1.0
File Size Uploaded
kirby_api-0.1.0.tar.gz 403.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for kirby-api 0.1.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page