Features
A running log of what Tome of Sessions can do. Update this file whenever you
add, change, or remove a feature (see AGENTS.md).
Status key: β done Β· π§ in progress Β· π planned
Last updated: 2026-07-02 Β· Recent changes are logged in the Changelog at the bottom of this page.
Authentication & accountsβ
- β Email + password sign up (bcrypt-hashed passwords, 12-character minimum)
- β
Login issuing a JWT (7-day default expiry) delivered as an httpOnly
session cookie β never stored in localStorage;
Authorization: Bearerstill works for API clients and tests - β
Sign out with real revocation β
POST /auth/logoutclears the cookie and bumps the user'stokenVersion, invalidating every outstanding token (all devices). Password reset bumps the version too, so a hijacked session dies with the reset. - β
GET /meβ current user + derived profile stats (campaigns, sessions, weekly streak) - β
PATCH /meβ update display name / character / avatar colors - β Frontend auth context with cookie-based sessions and route guards
- β Abuse hardening β rate-limited public invite preview, per-account daily cap (3) on password-reset emails, HSTS when served over HTTPS, server-side logging of 401/403s for intrusion spotting
- β
Password reset β
/forgot-passwordrequests a single-use link (SHA-256-hashed in DB, 1-hour TTL);/reset-password/:tokensets a new password. Delivered via Resend (falls back to console in dev). - β
Email verification on signup β auto-issued at signup, confirmed via
/verify-email/:token. Unverified accounts can still sign in but see a dismissible banner with a "Resend link" action. Delivered via Resend (falls back to console in dev). - β
Discord SSO β
Sign in with Discordbutton auto-appears on Login / Signup whenDISCORD_CLIENT_ID+DISCORD_CLIENT_SECRETare set. Signed short-lived OAuth state for CSRF; auto-links to an existing tome account with the same email, otherwise creates a passwordless SSO account (verified inheritor of Discord's email-verification flag).
Campaignsβ
- β Create a campaign (creator becomes DM)
- β List campaigns the user belongs to, with their role per campaign
- β Campaign detail (members, schedule, system, tagline)
- β
Tabbed campaign detail β Overview (campaign art slimmed to cover +
table-size pill, above an inline-editable Campaign details card) hosts a
sub-tab bar: The Party (roster + DM management forms, visible to all
members) and Sessions (the session list with mark-played-with-notes /
delete) for everyone, plus DM-only Setting (campaign preferences) and
Rules Docs (the document upload/index UI) sub-tabs. Top-level tabs are a
DM-only Rules Chat (placed right after Overview), Schedule (the
Scrying Pool + add-a-session form), Active Session (when an upcoming
session exists), a player-only My Character tab (set the name + class/role
shown for you in this campaign), and a DM-only Combat Tracker. Each tab is
its own route (
/campaigns/:id/overviewΒ·/ruleschatΒ·/scheduleΒ·/sessionΒ·/characterΒ·/combat) and the Overview sub-views deep-link their own segment too (/overview/party,/overview/sessions,/overview/setting,/overview/documents) so any view can be linked directly; the bare/campaigns/:idredirects to Overview. The Overview > Sessions "Schedule" button jumps to the Schedule tab. The tab selector hides itself when only one tab is visible. - β Update campaign (DM only): name, tagline, system, status, status note, schedule
- β
Inline-editable Campaign details on the Overview tab β a
CampaignOverviewcard lists Name, Tagline, Game System (sharedSYSTEMSdropdown with an "Otherβ¦" free-text fallback), and How the party gathers (Scheduled day/time/venue vs. on-demand "Scryed ad hoc"). Each row has its own Edit/Save that patches just that field viaPATCH /campaigns/:id; players see the values read-only (no Edit buttons) - β
DM-only campaign Setting sub-tab (inside Overview) β a preferences
panel holding an AI improve Session Notes toggle, a persisted campaign
boolean (
aiImproveSessionNotes, default off) saved viaPATCH /api/campaigns/:id. When on, the mark-as-played modal gains the Review with the Sage AI notes-improvement step (see Sessions). The rules-doc upload/index UI is its own sibling Rules Docs sub-tab. - β Delete campaign (DM only)
- β Campaign status: active / paused / concluded, with a free-text status note (e.g. "Resumes after July")
- β Schedule kinds: unset / scheduled / recurring / scrying / on-demand
- β
Campaign cover image upload β DM uploads PNG / JPEG / WebP / GIF (max
5 MB) from the campaign header; files served from
/uploads/covers/... - β
Campaign duplication β DM duplicates a campaign from Overview (
POST /api/campaigns/:id/duplicate), copying settings, rulebook opt-ins, documents with embeddings, monsters/items, NPCs, entities, and encounters (with session links stripped); excludes players, sessions, polls, invites, combat, chat history, and recap extractions; new campaign starts ACTIVE under the duplicating DM
Party / membersβ
- β Add a player to a campaign by email (DM only)
- β Static (offline) members β DM adds a player who isn't on the platform by name + class/role. They show in the party and are auto-added to the combat tracker, but are excluded from the Scrying Pool vote (no account to vote with)
- β Remove a player (DM only; DM cannot remove themselves)
- β
DM handover β transfer a campaign to another member (
POST /campaigns/:id/transferwithtoUserId+keepAsPlayer); the outgoing DM either stays on as a PLAYER or leaves the campaign - β Per-campaign character names; roles (DM / player) β a player sets their own on the My Character tab; the DM can set any member's character name / role from a roster action on the Overview > The Party sub-tab
- β
Per-player character level (1β20) β a
Membership.leveldefaulting to 1 when a member is added, editable (number input, clamped 1β20) in the same character forms (the player's own and the DM's edit-member modal) and shown in the party roster subtitle (e.g. "Wood Elf Wizard Β· Lv 5"). Saved via the sharedCharacterPatchBodyonPATCH /campaigns/:id/meand/memberships/:mid - β Invite links β DM generates a shareable URL from the campaign screen (copy / revoke pending invites), addressee or open links both supported
- β
Player-side "join with invite" screen at
/join/:tokenβ previews the campaign and inviter, bounces logged-out viewers through/login(preserving the return path) and creates the membership on accept - β
Real-time invite removal β when a player accepts an invite the DM's pending
invite list updates instantly via SSE (
invite_acceptedevent) and the new member appears in the members list without a page reload
Sessionsβ
- β List a campaign's sessions
- β Create a session (DM only)
- β Update / delete a session (DM only)
- β Session fields: title, scheduled time, duration, platform, agenda, number, status
- β Cross-campaign "Upcoming Sessions" feed for the signed-in user
- β "The Scrying Pool" scheduler UI β DM proposes candidate nights (datetime picker, add/remove rows) directly from the campaign detail screen
- β Session scheduled notification β all non-DM campaign members receive an email when the DM creates a session.
Encounters & the Active Session tabβ
- β
Encounters are campaign-level (DM only) β prepared with a name, optional
notes, and monster rows (name, count, AC, max HP, initiative modifier), they
live on the campaign and can be created and run at any time, with or
without an active session. An encounter can optionally be linked to the
active session via a checkbox shown only while a session is active; a linked
encounter's card shows "Linked to Session #N Β· Title". Deleting a session
unlinks its encounters (they survive). Templates: running one never changes
it, so it can be re-run after a reset. Each card shows the name with its
difficulty badge, the monster summary, optional notes, the "Linked to β¦" line
when linked, and a "Prepared
<date>" line (createdAt). - β
Encounter difficulty estimate (2024 DMG XP budget) β each encounter
response embeds a
difficultyobject: tier (Trivial/Low/Moderate/High/Deadly), totalencounterXp, the XP budget thresholds (low/moderate/high), party size, and anunratedCountfor monsters without a CR. Tier is derived from the campaign's player levels (no encounter-size multiplier, per 2024 rules); Deadly triggers at 1.5Γ the party's High budget (there is no official 2024 Deadly budget). Per-monster Challenge Rating is auto-filled from the SRD when a monster is selected, and editable via a CR dropdown (values"0""1/8""1/4""1/2""1"β¦"30") for custom/homebrew monsters. Encounters with unrated monsters display the difficulty as a lower bound. The encounter editor shows a live difficulty badge as the DM edits rows β computed client-side from the current rows + the party's levels (mirroring the server inweb/src/lib/encounterDifficulty.ts) β and both the live badge and the saved-encounter badge use a green (Trivial/Low) / yellow (Moderate) / red (High) / solid-red (Deadly) traffic-light scheme. - β
AI-suggested encounters (DM only) β a "Suggest with AI" control
next to Prepare encounter opens an inline bar (optional theme hint + tier
selector) and calls
POST /campaigns/:id/encounters/suggest. The server splits math from judgment: the LLM only picks a thematically coherent subset of the campaign's own monsters (falling back to stored core-rule monsters within the party's CR band) + counts + a name/flavor note, while all stats and the recomputeddifficultycome from theMonsterrecords and the deterministic 2024 budget math (XP derived from CR viacr_to_xp, never the storedxp). Theme matches are ranked first (a multi-word hint matches on any single word β "Kobold Warren Ambush" surfaces a Kobold). The chosen tier is an enforced ceiling, not just a hint: the server trims counts so the total XP never exceeds the tier's budget (dropping a creature that alone exceeds it), so a Moderate request can't come back High/Deadly. The unsaved suggestion pre-fills the normal create form for the DM to review, edit, and save β nothing persists until they hit save.400when no party levels are set;503(with the control hidden afterwards) when AI generation isn't configured βfakepicks are honored only when explicitly enabled, never as the implicit no-key fallback. - β
Source-labeled monster autocomplete (SRD + local DB) β the monster name
field in both the encounter builder and the Combat Tracker add-combatant
row autocompletes across three sources at once: the campaign's own extracted
monsters (Campaign), the shared core rules (Core), and the D&D 5e SRD
proxy (SRD), each result shown with an inline source badge. Sources are
queried in parallel and fail soft independently. Picking any result autofills
the stats (AC, HP, initiative modifier; CR too in the encounter builder); in
the Combat Tracker the chosen monster's initiative modifier rides onto the
combatant so its per-row d20 die rolls d20 + modifier. Local results come from
GET /api/campaigns/:id/monsters/search(now returning asourceof"campaign"/"core"); SRD results from thednd5eapi.coproxy. - β
One-click run β
POST /encounters/:id/runrebuilds the War Table: party re-seeded as blank rows, monster counts expanded into numbered copies ("Goblin 1, Goblin 2, β¦", HP pre-filled), round reset. If a fight is mid-round the server answers 409 and the UI asks before replacing it. - β
"Active Session" campaign tab β appears only while an upcoming session
exists (earliest not-played, not-cancelled; flips forward automatically as
sessions are played). Everyone sees the session header (title, date,
platform, agenda) and, for the DM, the full-width live session notes editor.
(Spec:
docs/superpowers/specs/2026-06-11-session-encounters-design.md) - β "Encounters" campaign tab β a DM-only tab listed directly under Active Session (in both the desktop rail and the mobile tab bar), always visible to the DM (no longer gated on an active session). Hosts the campaign's encounter prep list with Run / Edit / Delete (and the AI-suggest / difficulty tooling). Running an encounter jumps straight to the Combat Tracker tab.
- β NPC roster β DM-only tab with AI generation via Sage chat (prep mode) or a dedicated "Generate NPC" button and manual CRUD; campaign-grounded or generic generation; saveable NPC card in chat.
- β
NPC profile page β each NPC opens at its own DM-only URL (
/campaigns/:id/npcs/:npcId) as a full read+edit profile (replacing the view/edit modals), with an uploadable profile photo stored under/uploads/npc/(POST/DELETE /api/campaigns/:id/npcs/:npcId/photo, reusing the cover upload pattern) and a new nullableNPC.photoUrlcolumn (Alembicd5c6e7f8a9b1). - β
Rollable tables in chat β DMs can ask the Sage (prep mode) for a random table (e.g. "a table of tavern rumors", "effects of a mysterious flask", "a d100 table of deities in my campaign"). A new
"table"prep sub-task (classify_plan_subtask+extract_table_request) routes to_table_dispatchingraph.py, which optionally grounds the table in retrieved campaign notes (RAG, like NPCs), streams atableSSE event, and persists the card on a newRulesChatMessage.tablecolumn.app/lib/table_suggest.pynormalizes the die to a standard size and guarantees rows tile1..die(math split from LLM judgment, like loot). The<TableCard>renders the numbered table inline with a client-side, re-rollable Roll button that highlights the matching row. - β
Live session notes in Active Session tab β a full-width rich-markdown
editor panel in the DM's Active Session tab. Typing autosaves to
session.notesviaPATCH /sessions/:idwith a "Savingβ¦" / "Saved" status indicator, debounced to avoid chat-like flurry. Notes persist across reload and render as Markdown in played-session list previews (2-line clamped). The toolbar is themed to the tome palette β icon buttons adopt the app's mutedβink hover and an accent-tinted active state, with a tome-coloured divider replacing the library's default bar. - β
Mark as played, with title β the DM-only button on the Active Session
tab and the "Mark played" action on any Sessions-list row both open a dedicated
wide modal (
SessionEditorModalin play mode) with an editable title and the same full rich-markdown notes editor below it (sharedNOTES_COMMANDStoolbar), so the DM can review and edit the notes before committing. Saving writestitle+notesand flips the session to played viaPATCH /sessions/:id; the Active Session tab advances to the next upcoming session and a 2-line Markdown-rendered preview of the notes shows under the played session in the list. New sessions are inserted client-side in the server's chronological order and de-duplicated by id, so a just-added session lands in its correct place rather than doubling up at the bottom until refresh (the create response and thesession_scheduledbroadcast previously raced β both now route through a sharedupsertSession). When the campaign's AI improve Session Notes setting is on, the modal adds a Review with the Sage step alongside Save as-is (only if notes exist): Review callsPOST /campaigns/:id/notes/improve(with thesessionId), which retrieves the campaign rules/adventure and has the generator polish the notes β fixing grammar/structure (light Markdown) and filling in proper names (places, NPCs, monsters) grounded in the retrieved sources, never fabricating. It passes the session's position (number/title/agenda) and earlier-session notes as context, so e.g. a first session biases toward the opening chapter. The result is rendered as Markdown (via a smallMarkdowncomponent) in a readonly pane with Done (save) or Back to edit. The improve call persists nothing; the session is written only on save. Works with zero indexed documents (rules context is optional). - β
Edit any played session (DM only) β an Edit button on every
played-session row opens
SessionEditorModalin edit mode: the DM can update the title and notes at any time, with an optional Review with the Sage AI-improve step (same flow as mark-played, available when the campaign'saiImproveSessionNotesis on). The Re-open button is hidden (not disabled) when another session is already active, so it only appears when re-opening is actually possible. Long played-session notes expand/collapse inline β a "Show more" / "Show less" toggle appears when the notes preview exceeds the 2-line clamp, so the list stays compact by default.
Combat tracker (DM only)β
- β Per-campaign initiative tracker β DM adds combatants (initiative β with a d20 auto-roll β name, AC, HP). Each combatant carries an initiative modifier (from its encounter monster); a per-row die rolls d20 + the modifier and writes the result. The DM also edits initiative/AC/HP inline, drags rows to reorder turn order in real time (or one-click Auto-order by initiative), and tags color-coded 5e status conditions from a dropdown
- β Party auto-seeded β the campaign's players are added as blank combatants (name only) when the tracker is first opened and again after a clear, so the DM only fills in their initiative / AC / HP
- β Turn order β start combat, advance turn (auto-incrementing the round on wrap), end combat, and clear the encounter; state is server-persisted and invisible to players
- β
Bloodied indicator β each combatant row has an editable Max HP
field beside current HP; when current HP drops below half of max, a
crimson Bloodied tag appears by the name and the HP value turns crimson,
so the DM spots hurt creatures at a glance (frontend-derived from the existing
hpCurrent/hpMax; combatants with no known max are never flagged)
Availability ("The Roll Call")β
- β DM proposes candidate dates (creates an availability poll); campaign flips to "scrying"
- β Members set their own availability marks (yes / maybe / no) per candidate date
- β Availability grid (players Γ dates) rendered on the campaign detail screen
- β Close a poll (DM only)
- β Auto-highlight the best date based on responses (weighted yes/maybe score, with per-night tally surfaced in the Roll Call grid)
- β Promote a poll's winning date into a scheduled session in one click (DM only; creates the session, closes the poll, marks the campaign scheduled)
- β Scrying Pool opened notification β all non-DM members receive an email when the DM opens an availability poll.
- β All votes in notification β DM receives an email (with best-date tally) when every player has submitted their Roll Call responses.
- β Scrying promoted notification β all non-DM members receive an email when the DM promotes a scrying result to a confirmed session.
Real-time updatesβ
- β
Server-Sent Events (SSE) endpoint β
GET /campaigns/:id/eventsstreams named events to connected clients; campaign-scoped, authenticated via the session cookie (no more token-in-URL), with 30-second keep-alive pings and automatic browser reconnection - β
scrying_openedβ broadcast to all campaign clients when the DM opens a new availability poll - β
vote_castβ broadcast with the full updated poll DTO when a player submits availability marks - β
poll_promotedβ broadcast with the new session DTO when the DM promotes a poll to a confirmed session - β
session_scheduledβ broadcast when the DM creates a session directly - β
session_endedβ broadcast when the DM marks a session as played or cancelled - β
useCampaignEventsReact hook β opens an EventSource per campaign, dispatches events to caller-supplied handlers, and triggers a full re-fetch on reconnect
Profileβ
- β Profile screen with avatar, name, character, stat trio, account rows, sign out
- β Edit profile from the UI β display name, character, and avatar gradient (6 presets) editable inline on the Profile screen
Admin / Platform Settingsβ
- β
Admin role + Platform Settings β boolean
User.isAdmin(bootstrapped viaADMIN_EMAILS); admin-only User Management (promote/demote/delete with guardrails) and Core Rule Management (private PDF/Markdown corpus per setting, D&D for now; future RAG source) β multi-file upload with the filename used as the title. Endpoints under/api/admin/*.
Rules chat & document ingestionβ
- β
Persisted extraction + "Process" action β ingest now saves the
extracted Markdown as a sidecar next to the source file (
<token>.extracted.mdin private storage) and stampsextractedAton the doc. A new Process button beside Re-index (andPOST /admin/core-rules/:id/process/POST /campaigns/:id/documents/:docId/process,400until first indexed) re-derives chunks/overview/monsters from that saved Markdown without re-parsing the source β so newly-added extractors or changed chunk settings can be applied without re-running PDF extraction. Monster extraction is now resilient: a mid-run API/rate-limit error keeps the monsters already parsed (the loop stops and persists the partial set) instead of discarding the whole run, so an interrupted Monster-Manual ingest no longer loses everything. - β
Monster extraction during ingest β Core Rules and Campaign documents now
have their stat blocks extracted (LLM structured extraction, best-effort so it
never fails indexing; cheap Armor Class/Hit Points prefilter skips narrative
pages) into the dedicated
Monstertable, tagged by source and flaggedneedsReviewwhen AC/HP/CR are missing. A campaign-scoped API (GET /api/campaigns/:id/monsters/search+/:monsterId) serves them in the encounter-builder autofill shape (UI wiring deferred). - β
Campaign document ingestion β a DM uploads campaign PDFs/Markdown
(adventures, house rules) per campaign; each document is parsed via Docling β
header-aware chunked β embedded (Voyage, with a deterministic fake when no key
is set) β indexed into a pgvector-backed
Chunktable, with index status, re-index, and delete. Core rules are indexed on upload too. DM panel under the campaign's Overview β Rules Docs sub-tab; endpoints under/api/campaigns/:id/documents. The uploaded-file list renders through a sharedDocumentRowcomponent (used by both the campaign-docs and platform core-rules screens): a leading book-icon tile, title + metadata, and a dot-based status pill vertically centered with the Download / Re-index / Delete actions β colour rides on the dot (accent = indexed, pulsing amber = pending/indexing, crimson = failed) so the pill aligns cleanly in every state and long titles truncate without breaking the row. - β
Whole-document Overview synopsis β at campaign-document ingest, Claude
summarizes the full extracted text into a short synopsis (premise/plot,
factions, NPCs, locations, hooks) stored as an
Overviewchunk (sectionPath="Overview", sentinelordinal=-1). Retrieval guarantees the Overview a slot: as one chunk among hundreds it loses any plain ranking fight and starves out of the top-k, soretrieve()fetches the campaign's synopsis chunk(s) by the sentinel ordinal and reserves them a slot (trimming the lowest-ranked content). Without this, synthesis questions ("what's the plot of this campaign?") only saw fragmentary scene chunks and wrongly abstained β a query router was considered and rejected as overkill for a deterministic single-chunk fix. Best-effort: a summary failure (API/content-filter) is logged and skipped, and the document still indexes normally. Large docs are single-pass with a clip + truncation note. - β
Per-campaign rules chat (DM-only) β hybrid retrieval (dense + BM25 + RRF)
over the core-rule + campaign-document index, with a curated query-expansion
alias map that bridges D&D vocabulary gaps before retrieval (e.g. "wizard
subclasses" β "Arcane Tradition / School of Magic"); the map is a checked-in,
comment-friendly Python data file (
server/app/lib/rag/aliases.py) edited to close new gaps. Played-session recaps are always injected as citeable sources (chronological, budget-clipped), so the chat answers campaign-history questions like "What happened so far in this campaign?" β synthesised from the session notes and cited as "Session #N Β· Title" β and can answer them even when no documents are indexed. Anthropic cite-or-abstain generation with a citation guardrail, conversation history; abstains when the rules don't cover it. The Claude model is configurable viaGENERATION_MODEL(defaults to Sonnet; set toclaude-opus-4-8to test Opus). PDF ingestion is tunable from a platform-admin Ingestion tab, per source type (Core Rules vs Campaign Rules): an extraction method selector βlocal(Docling),local_ocr(Docling + OCR), oranthropic(Claude reads each page with vision; far better for image-heavy PDFs, uses the API atPARSING_MODEL). Anthropic parsing falls back to local Docling only when no API key is configured; a parse error fails the ingest with the real cause instead of silently committing a broken text-layer extraction (which on a PDF with bad font encoding would index unreadable garbage that looks "indexed"). A content-filter block is the exception: when Anthropic's output filter blocks a page batch (invalid_request_error: "Output blocked by content filtering policy"), that batch is retried a page at a time so one offending page doesn't fail the whole document; a page still blocked alone is replaced with a visible placeholder and the rest indexes. Plus linked Max-chars / Overlap sliders (overlap auto-clamps to half of max chars). Settings are stored in the DB and apply to the next upload or re-index. Anthropic parsing transcribes page batches concurrently (reassembled in page order). Ingests interrupted by a server restart/crash (in-process background tasks don't survive one) are recovered at startup β any document left inprocessing/pendingis flipped tofailedwith a note so it can be re-indexed, instead of hanging forever. Generation is fed the full retrieved chunk text, not the short citation snippet, so answers that sit past the snippet preview aren't invisible to the model (it no longer falsely abstains on rules that are in the corpus). The chat streams the answer (SSE): retrieved sources appear first, then the answer types in token-by-token, in a real chat-bubble UI with persistent history (Enter to send, Shift+Enter for newline). If generation fails mid-stream (API billing/rate-limit/network), the server emits a terminalerrorSSE event (logging the real cause, persisting nothing) and the client surfaces a calm error instead of hanging on "Consulting the tomesβ¦" forever β the consumer also treats a stream that ends without adoneevent as an error, so a dropped connection can't leave the UI stuck loading. Inline citations render as small superscript footnote numbers so they don't break the reading flow; the cited sources are listed as numbered pills in a Sources footer beneath each answer, each revealing a styled hover popover (the Sage card aesthetic) with the document title, section breadcrumb, and source snippet. The chat view is locked to the viewport height β only the message thread scrolls; the header, question counter, and input stay fixed and the card never expands. - β
Stat-block tool in chat β asking the Sage to "show me the stat block
for
<monster>" triggers a genuine LLM tool call (get_stat_block, bound via the Anthropic tool-use API). The orchestrator reads the monster from this campaign'sMonstertable plus the shared core rules (exact case-insensitive match, then alphabetical prefix fallback; never another campaign's rows), emits a newstat_blockSSE event carrying a full stat-block DTO (serialize_monster_stat_block) plus the monster's source document as its self-citation, persists a card-only assistant turn (RulesChatMessage.statBlock, nullable JSON), and finishes β no prose, no abstention. A miss falls through to the normal retrieveβstreamβguardrail path so no empty card appears. The card renders via a reusable presentational<StatBlock>component, styled as an aged grimoire bestiary page (parchment, crimson illuminated headers, the signature tapered 5e divider bars, engraved display type) withfull+compactvariants and UI-derived ability modifiers. Free-form extractor entries (traits/actions/legendary stored as plain strings) are normalized to{name, desc}at extraction and serialization time (with a data migration backfilling existing rows), so the card always renders named entries. EndpointGET /api/campaigns/{id}/monsters/{monsterId}/statblock(member-scoped) exposes the same DTO; a siblingGET β¦/monsters/statblock?name=resolves by name. - β
Stat block in the Combat Tracker β a monster/NPC combatant's name is
clickable (dotted accent underline, firming to a solid accent underline on
hover) and opens its stat block in a modal; player-character names render plain
since they have no stat block. Resolved by name through the by-name
endpoint (combatants carry a name, not a monster id; a name with no matching
monster shows a friendly empty state). First reuse of the
<StatBlock>component outside chat. The modal opens in a newbaremode (<Modal bare>) that drops the card chrome and sizes the dialog tofit-content, so the self-framed grimoire card is centered and hugged tightly instead of stranded left-aligned inside an over-wide dialog with empty space on the right. The button is hidden for player characters β combatants now carry a persistedisPlayerflag (Combatant.isPlayer, set true for party-seeded rows, false for monsters/manual adds; an Alembic migration adds the column and backfills existing party rows by name), so only monsters show the stat-block button. - β
Stat blocks resolve by reference β encounter monsters and combatants now
carry a
statBlockReflocator (campaign:<id>/core:<id>/srd:<index>) captured at monster selection in the encounter builder; the War Table's stat-block button resolves the right creature for numbered copies ("Goblin 1") and SRD monsters viaGET /api/campaigns/:id/monsters/statblock/by-ref, with SRD blocks mapped from the full dnd5eapi payload. Hand-typed combatants and legacy encounters fall back to name resolution. - β
Dice-roller tool in chat β asking the Sage to "roll 2d6+3", "roll a
d20", or "roll 2 d10" triggers a
roll_diceLLM tool call: the orchestrator parses standardXdYΒ±Znotation (count 1β100, sides 1β1000, modifier Β±1000), rolls server-side, and emits a newdice_rollSSE event with each die face, the modifier, and the total β card only, no prose, no citation. The card-only assistant turn persists (RulesChatMessage.diceRoll) so reloads re-render it, and unparseable/out-of-bounds notation returns a friendly error card instead of falling through to retrieval. Rendered by a new<DiceRollCard>. - β
Optional Langfuse LLM tracing: when
LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEYare set, the Anthropic SDK is auto-instrumented and each user-facing operation is one grouped trace with its LLM calls nested underneath β no orphan/scattered traces. Rules-chat turns (streaming and non-streaming) trace retrieveβgenerateβguardrail, tagged with the conversation as the Langfuse session and the campaign as metadata; the streaming path holds one span across the whole turn (including persistence) so it isn't a detached generation. Per-mode trace tagging: the chat mode (rules/prep/recap) is stamped on each trace as both a first-class Langfuse tag andmodemetadata, so traces are filterable per mode. The streaming adapter resolves the mode up front (sharedresolve_modehelper,app/lib/rag/ graph.py) β before the trace opens, since Langfuse only tags spans created after the trace starts β and passes the resolved value into the graph so the router doesn't re-classify; the non-streaming path is taggedrules. Document ingest is one trace tagged with the document/campaign, with each per-batch Anthropic-vision parsing call nested under it β the parser'sThreadPoolExecutorre-attaches the trace context into its workers so batches don't scatter. Each trace also records a legible input/output on its root span β the question/answer for chat turns, the document descriptor and{status, chunks}for ingests β so the Langfuse trace list's Input/Output columns are readable without drilling into child spans. Shared, no-op-when-off helpers live inserver/app/lib/rag/tracing.py. Disabled and zero-overhead when keys are absent; tracing failures never affect behaviour. Self-hosting: setLANGFUSE_HOST.
Platform / infrastructureβ
- β
Branch + PR workflow with CI β direct pushes to
mainare disallowed; GitHub Actions (.github/workflows/ci.yml) runs server lint (ruff) +pytest(with a pgvector Postgres service), the web type-check/build + vitest, and the docs-site build on every pull request - β
Backend rewritten in Python/FastAPI (SQLAlchemy 2 async + Alembic,
Pydantic v2, managed with
uv) β the HTTP API contract is unchanged; the React frontend works as-is. pytest suite replaces the previous vitest/supertest setup. - β
PostgreSQL schema via SQLAlchemy models + Alembic (models in
app/models.py, migrations inserver/alembic/) covering all entities - β
Seed script with the prototype's sample world (
uv run python seed.py) - β FastAPI app with centralized error handling + Pydantic validation
- β Vite + React + TypeScript SPA, responsive (mobile bottom tabs + desktop side rail)
- β
Centered desktop content column β
--content-maxwidth ladder shared by the screen body and the appbar's inner wrapper, so whitespace splits evenly on wide monitors and the title stays over the column's left edge (spec:docs/superpowers/specs/2026-06-11-centered-desktop-layout-design.md). The appbar is fully constrained to the column: a shared--pad-xgutter ladder keeps the header text, back button, and scrolled bottom border/shadow aligned with the content's text edge at every breakpoint; only full-bleed elements (the unverified-email notifications banner) span the viewport - β
Contextual navigation β campaign tabs live in the chrome: in the desktop
rail between "Campaigns" and "Upcoming" (divider-separated, with the
campaign name as an eyebrow), and as the mobile bottom bar while on a
campaign.
CampaignDetailpublishes its computed tabs throughCampaignNavContext; the in-page segmented selector is gone (spec:docs/superpowers/specs/2026-06-12-contextual-sidebar-nav-design.md). Creating a campaign is reached from a + action in the Your Campaigns header (plus the empty-state button); the standalone "Create" rail/bottom-bar tab was removed. Every tabbed surface is URL-driven so it deep-links: the campaign tabs and their Overview sub-tabs (Sessions, Setting, Rules Docs) and Platform Settings sections (/admin/:tab). The desktop side rail can be collapsed to an icon-only strip via a hamburger toggle next to the logo (which replaces the logo when collapsed); labels become hover tooltips, each nav icon gets its own square container, the choice persists inlocalStorage, and the reclaimed width extends the content column - β
UI consistency layer β shared
ErrorAlert/EmptyState/BackBtn/PasswordField/Selectcomponents (theSelectreplaces the OS dropdown arrow with a consistently-aligned caret),Modalwith focus trap, a CSS radius scale +.btn-small/.seal.danger/.seal.suggestvariants,:focus-visiblerings on all interactive elements, and Modal-based confirmation for every destructive action (nowindow.confirm). Tracked inImprovements.md. - β Docker Compose for local Postgres
- β
Root dev helpers:
./start.shlaunches the Vite web app on port 5559 and the FastAPI API on port 4000;./stop.shfrees those ports by terminating any listener processes left behind after development runs. - β
CI pipeline β GitHub Actions runs server tests + lint and web build
on every push / PR (
.github/workflows/ci.yml) - β
NPC eval harness (
server/evals/) β offline LLM-judge scoring of generated NPC quality: a versioned decomposed-binary rubric (rubric.py), a Claude Opus 4.8 judge with structured output so malformed verdicts raise instead of silently scoring 0 (judge.py), and golden-set validation reporting judge-vs-human Cohen's kappa (validate.py).run_llm_judge.pyscores the full case set and writesevals/out/npc_eval.json;--push-langfuseships results to Langfuse. Offline tests run withFakeGenerator+FakeJudge. Closes the NPC slice of deliverable #7. - β
Citation-accuracy eval (
server/evals/) β 50 frozen PHB rules questions (rules_questions.jsonl, ~8β9 per topic; required + acceptablesectionPaths) scored over the live rules-chat pipeline (run_citation_eval.pycallinganswer_rules_question): precision / recall / F1 + abstention rate, macro and per-topic. Matching is tolerant (normalize.pyabsorbs OCR-noisy breadcrumbs β leaked page numbers, duplicated segments, depth β but not character errors); scoring/aggregation is pure and offline-tested (citation_metrics.py). Pipeline errors are recorded per-row and excluded from aggregates, never scored0. Run:EVAL_CAMPAIGN_ID=<id> evals/calibrate.sh citation; writesevals/out/citation_eval.json. No judge β no calibration. Closes deliverables #3 / #4. - β
Encounter-math eval (
server/evals/) β 10 frozen scenarios (encounter_scenarios.jsonl, TrivialβDeadly, varied party sizes/levels) whoseexpected_tier/expected_xpare hand-computed from the published 2024 tables.run_encounter_eval.pyfeeds each through the productionestimate_difficultyand asserts an exact tier + XP match β deterministic pass/fail, no network, no key, CI-runnable. Run:evals/calibrate.sh encounter; writesevals/out/encounter_eval.json. Closes deliverables #5 / #6. - β
Integration tests against a real Postgres β
env -u NODE_ENV uv run pytest testsinserver/. Uses pytest + httpx against the docker-compose Postgres, truncates all tables between tests, and covers signup β campaign β invite β poll β promote β upcoming. GitHub Actions runs them on every push against an ephemeralpostgres:16service. - β
Deployment via Forge β
root-level
install:all/build/startscripts;npm startdrivesuv run python -m app.runwhich runs ensure-db +alembic upgrade headon boot then starts uvicorn; the API serves the built web app fromweb/dist/;GET /healthfor Forge health checks; binds to$HOST:$PORT(defaults0.0.0.0:4000)
Notesβ
- The original design prototype lives in
prototype/Tome of Sessions.htmlfor reference. - API reference and setup steps are in
README.md. - Post-rewrite bug-fix pass (2026-06-13):
DELETE /campaigns/:id/combatnow commits the reset (was discarded on session close); signup / password-reset accept passwords up to the 200-char schema limit again (bcrypt 5 rejects72 bytes β now truncated like the old bcryptjs); fire-and-forget notification emails hold a task reference so they can't be GC'd mid-send.
Changelogβ
One dated line per feature change, newest first. Add a bullet here (and bump
the Last updated date) whenever you ship a change; keep it to a single
sentence. Full implementation detail lives in this file's git history, the
topic pages of this docs site, and docs/superpowers/.
- 2026-07-02 Β· AI-suggested encounters keep stat-block refs β suggestion rows now carry
statBlockRef, so combatants from AI-generated encounters open their stat block in the combat tracker (previously "No stat block found" for numbered names like "Owlbear 1"). - 2026-07-02 Β· PR template β pull requests are pre-filled with What / Why / How sections and a definition-of-done checklist (
.github/PULL_REQUEST_TEMPLATE.md). - 2026-07-02 Β· Branch + PR workflow and rebuilt CI β direct pushes to
mainare disallowed (feature branch β push β PR); the stale Node/Prisma GitHub Actions were replaced with jobs for server lint + tests (pgvector service), the web build + tests, and the docs build. - 2026-07-02 Β· Feature ledger moved into the docs site β the root
FEATURES.mdnow lives here asdocs-site/docs/features.md;./start.shalso serves the docs dev server (DOCS_PORT, default3050). - 2026-07-02 Β· Developer documentation site β Docusaurus maintainer manual in
docs-site/covering the whole system; rootnpm run build/npm run startbuild and serve it. - 2026-07-02 Β· Campaign duplication β DMs duplicate a campaign (settings, documents with embeddings, monsters, NPCs, plot entities, encounters) while players, sessions, and history are excluded.
- 2026-07-01 Β· Per-user AI access gating β
User.aiAccess(or admin) gates every AI endpoint viarequire_ai_access; admins toggle it per user in Platform Settings. - 2026-07-01 Β· Sage note review is always on β the per-campaign toggle is removed; "Review with the Sage" is offered whenever a played session has notes.
- 2026-06-30 Β· Core rule usage policy β rulebooks carry a
required/optional/toolsusage; optional books need a per-campaign opt-in (DM "Core Sources" sub-tab) before the Sage retrieves them. - 2026-06-30 Β· AI encounter difficulty matches the requested tier β the builder now targets the same XP band the difficulty badge reads, fixing one-tier-low results.
- 2026-06-30 Β· Wax-seal brand mark β new
SealMarkspark-seal logo in the side rail, onboarding shell, and favicon. - 2026-06-30 Β· Uploaded images show in dev β the Vite dev server now proxies
/uploadsto the API. - 2026-06-30 Β· Codex loads fast while a recap is processing β recap extraction runs off the event loop so the app stays responsive during LLM calls.
- 2026-06-30 Β· Tracing stays silent when unconfigured β Langfuse tracing is a true no-op without keys instead of logging auth errors.
- 2026-06-30 Β· Citation & encounter-math evals β judge-free citation-accuracy and encounter-math measurements added to
server/evals/. - 2026-06-30 Β· Recap-review rows show the referenced entity/NPC β display names are backfilled onto update/link/close ops before a changeset is persisted.
- 2026-06-30 Β· Live NPC trace instrumentation β production NPC generation emits a filterable
npc.generateLangfuse observation for LLM-judge evaluators. - 2026-06-29 Β· NPC eval harness β offline LLM-judge harness scoring generated-NPC quality against a versioned rubric, with golden-set kappa validation.
- 2026-06-29 Β· Per-mode Langfuse trace tags β chat traces are tagged with their resolved mode (
rules/prep/recap). - 2026-06-29 Β· Generate NPC button β the NPCs tab generates a campaign-grounded NPC from free-text ideas, with save/regenerate.
- 2026-06-29 Β· AI / Sage visual language β one shared treatment for generative controls: gradient CTA, tonal secondary button,
β¦ Sagechip, fixed--ai-*tokens. - 2026-06-29 Β· Icons migrated to FontAwesome β hand-rolled SVGs replaced behind the unchanged
<Icon>wrapper; FontAwesome-only rule adopted. - 2026-06-29 Β· NPC profile page β per-NPC route with a full read/edit profile and photo upload.
- 2026-06-29 Β· Codex recap processing panel β recap extraction has a persisted lifecycle plus SSE events; the Codex shows live processing/failure states.
- 2026-06-29 Β· NPC living state β NPC status, per-session appearances, and directed relationships, populated by the recap extractor and editable in the UI.
- 2026-06-28 Β· Recap entity extraction β living Codex β played-session recaps become DM-reviewed changesets maintaining plot threads/quests/locations in a new Codex tab.
- 2026-06-28 Β· Rollable tables in chat β prep-mode Sage generates rollable tables rendered with a client-side Roll button.
- 2026-06-26 Β· Loot generation β loot from Sage prep chat and a Combat Tracker modal, sharing one budget-math + LLM brain.
- 2026-06-26 Β· NPC roster β NPCs generated in prep chat or managed manually on a new NPCs tab, backed by a new
NPCtable. - 2026-06-26 Β· Encounter-in-chat (prep mode) β prep chat builds saveable encounter cards using the encounter-suggest brain, streamed as SSE events.
- 2026-06-25 Β· LangGraph chat modes β Rules Chat runs through a LangGraph router (
rules/prep/recap) with the SSE contract unchanged. - 2026-06-25 Β· Dice-roller tool in Rules Chat β a
roll_dicetool returns result cards forXdYΒ±Zrolls. - 2026-06-25 Β· Combat Tracker UI pass β creature names open stat blocks; FontAwesome d20 and blood-drop icons; distinct nav glyphs.
- 2026-06-25 Β· Party folded into Overview β the party roster and DM management became "The Party" sub-tab under Overview.
- 2026-06-25 Β· Consistency + query-optimization pass β N+1 list endpoints batched and repeated inline styles extracted; behavior-preserving.
- 2026-06-25 Β· Stat block in the Combat Tracker β combatant rows open a full stat-block modal via a by-name lookup endpoint.
- 2026-06-25 Β· Stat-block card redesign + entry normalization β grimoire-styled stat block; trait/action entries normalized to named objects with a data backfill.
- 2026-06-25 Β· Stat-block tool in chat β "show me the stat block for X" triggers a real tool call rendering a card-only answer.
- 2026-06-24 Β· Source-labeled monster autocomplete β encounter/combat monster fields autocomplete across Campaign/Core/SRD with source badges.
- 2026-06-24 Β· Encounters decoupled from sessions β encounters are campaign-level with an optional session link; routes moved under
/campaigns. - 2026-06-24 Β· Persisted extraction + "Process" action β ingest saves the extracted Markdown; Process re-derives chunks/overview/monsters without re-parsing the source.
- 2026-06-24 Β· Notes editor in mark-played modal β the full rich-markdown notes editor appears in the mark-played flow.
- 2026-06-24 Β· Overview/Party split + editable campaign settings β inline-editable campaign-details card on Overview; party briefly its own tab (later re-folded into Overview).
- 2026-06-24 Β· Session edit modal + inline notes expand/collapse β DMs edit played sessions any time; Re-open hides while another session is active; long notes collapse inline.
- 2026-06-24 Β· Encounters moved to their own campaign tab β DM-only Encounters tab; the Active Session editor gets the full width.
- 2026-06-24 Β· Live session notes in Active Session tab β an autosaving rich-markdown notes panel.
- 2026-06-24 Β· AI-suggested encounters β "Suggest with AI" assembles tier-budgeted encounters from campaign/core monsters; deterministic math, LLM judgment.
- 2026-06-19 Β· Encounter difficulty estimate β 2024 XP-budget difficulty badge, SRD-autofilled CR, and a per-monster initiative modifier with a d20 roll.
- Earlier (June 2026) Β· Rules Chat & ingestion foundations β hybrid retrieval with cite-or-abstain generation, streaming answers with footnote citations and hover popovers, crash-safe streams, per-document Overview synopsis chunks, query-expansion aliases, the admin Ingestion tab, Anthropic PDF parsing with per-page retries, grouped Langfuse traces, and a configurable generation model.
- Earlier (June 2026) Β· Sessions & campaign polish β the mark-played modal with grounded "Review with the Sage", Markdown note previews, campaign-history answers sourced from session recaps, per-campaign character levels, the sessions-list dedupe fix, the
DocumentRowredesign, and removal of the obsolete nested Next.js/Prisma scaffold.