A self-hosted, multi-user engine for character-driven interactive fiction.
Status: a work in progress, public but not released. This repository has
been public since 2026-10-07 (decided 2026-10-03), for reasons of licensing and
CI rather than readiness (releases §0.1a).
Nothing here is a release. The container image is a private package, the
v1.0.0-alpha.* tags are builds the project made for itself, and from alpha 5
each tag's Linux tarball is a workflow artifact anyone signed in to GitHub can
download for ninety days — a build, not a release. Nothing is supported, data
formats may change without a migration,
and issues and pull requests may go unanswered. It is not accepting
contributions for now, so a pull request will not be merged; forks are
welcome, under the AGPL (Licence). A security problem goes
privately, as SECURITY.md says, and not into a public issue.
Both are policy, recorded in
releases §0.1a with the date
they were set. Running it means building it from source (Running it).
The design is written down in docs/design/; the code is
through P15 of the
work plan — P13's whole-install
Aventuras import and P14's Scene and session import merged on 2026-09-29 and
2026-09-30, and P15's Setup made from a turn on 2026-10-03 — with several
phases' exit gates still awaiting a person, those three among them
(manual testing's sittings X, Y
and W);
the work plan's index records each phase's
state rather than glossing it. (This paragraph said "through P7" until
2026-10-03, seven phases after it stopped being true, and "through P14" until
2026-10-04, a day after P15 merged.) What exists: the storage spine and a derived
index that can be thrown away and rebuilt from it; accounts and sign-in; the
library, with import from SillyTavern, Marinara and Aventuras and export back
out again; the turn, end
to end, as a resumable server-side job with a streamed reply; the workbench
that reads the record of it; lorebooks as documents to read and as retrieval
that says why each entry fired or did not; the session as a tree you can
branch, rewrite, reroll and walk; the settings surface for an install, its
accounts and its model connections; and, since P6A, what a container needs — a
server that can be told where to bind, serves its own client, guards its first
admin with a token, and can say which commit it is.
Alpha 1 was tagged 2026-09-06, at P6A. Its version is 1.0.0-alpha.1,
named 1.0-alpha 1 — the first prerelease of 1.0, under the scheme
releases §7.1 records — in the
root package.json, in CHANGELOG.md and on the tag
v1.0.0-alpha.1, from which the on-tag workflow built the image on its second
run: the first stopped at the Dockerfile, because the base image no longer
ships corepack and nothing had run that file before a daemon did, and the tag
moved to the fix. The image is private, so nothing pulls it without a login
(docs/deploy.md). What has not happened is the walk —
P6A §3 steps 3 through 12, which
need a machine with Docker and an unraid host. It is a build the
project makes for itself, not a distribution: the registry package is private
— the repository was too, until 2026-10-07 — and
releases §0.1 says why that is
the point for the build rather than a stage on the way to something. Alphas 2
to 4 followed on 2026-09-07, -08 and -09, under the same terms, and alpha 5 on
2026-10-07, the first tag on a public repository: its image is as private as
the others, and its tarball is not
(releases §0.1a).
The UI browses, plays, reads and configures. Sign in and browse all six
kinds of library object, or fill the library from a SillyTavern, Marinara or
Aventuras directory — or one Aventuras scenario, character or lorebook file —
and read the review of what resolved, what went to compat and what dangled.
Take any object back out as its own JSON, or written as a character card or one
of Aventuras' own formats, with what each conversion could not carry named
beside it. Start a session, take a turn, watch the reply stream. Branch
from any earlier turn, rewrite or reroll a reply and move among the siblings it
leaves, undo the newest turn, and search the lines you abandoned — every one of
them is still there. From any turn, a wizard turns the story so far into a
Setup that new sessions start from, and that Setup's own opening wins turn 1
over the cast's greetings. Open the workbench beside Play to see what the turn was
built from: every block with its source and reason, the budget's verdicts, the
calls, the effects, which lore entries fired and which were skipped and why, and
a diff between two turns. Read a lorebook as a document — browse it, search it,
edit its entries and drag them into order. Open Settings to change your own
preferences or, as an admin, the install's configuration, its accounts and its
model connections.
Since P6B and P7: a mode is a package rather than a shape the engine happens to fit. Scene and Freeform are two of them, each consuming the published SDK and permitted to import nothing else — channels with their own schemas, their own setup wizards, their own steps, their own participant policies and their own contributed UI. Around that: plot hooks with a selector that says why it held or fired, goals as a chain with a cursor and a judge whose completion you confirm, party and presence as channels so a character can be dead on one branch and alive on another, mentions highlighted against what the lore scanner matched, two difficulty dials, suggested actions, and a staged scene with backdrops and per-actor expressions imported from the card that carried them.
Actors and lorebooks have editors, with automatic version history behind a
History control: every change snapshots the state it replaced, hand edits
included, and any version can be restored, diffed, pinned or renamed. Name one
on the library page to make it, and delete anything you own from its detail
page — the folder moves to your trash rather than being erased. The other four
kinds are read-only for now, so one of those arrives by import or through the
API (docs/api.md) rather than from a blank page; creation
follows each kind's editor. One gap worth knowing before you go looking:
choosing which lorebooks a session uses has no UI yet. Repaired at
P6B.0 — the create form and a
mid-session panel both set a session's books, and pnpm seed names the treatment
it builds. A book is in play only if the session names it or the treatment the
session names links it, and now both are reachable from a browser
(P5 §0.5).
The storage thesis does work end to end: create an actor through the API, watch the folder appear, hand-edit a lorebook on disk in a text editor, and see the change in the browser without a restart (10 §4.1). And it survives a second writer: saving over an object that changed on disk is refused, and the editor offers to reload-and-reapply or save as a copy rather than guess.
What has not happened is PLAYABLE (work plan §4.1) — the checkpoint where one person sits down with an imported library and plays, and the design gets tested by use rather than completed on paper. Alpha 1 is being cut before it, under the rule P6A §5 sets: it may be cut before PLAYABLE; it does not go public before it. The repository went public before it anyway — decided on 2026-10-03 and switched on 2026-10-07 — knowingly and for the reasons releases §0.1a gives; the image did not, and PLAYABLE is still the checkpoint ahead.
Start with docs/guide/ if you want to use it as it is
built today, docs/design/README.md if you want to know
what this is going to be, and docs/design/00-stance.md
if you want to know why.
Alpha distribution is build-it-yourself (releases §0).
There is one channel, testing, which every tagged alpha moves and the unraid
template follows; no latest, and no packages anyone else installs. The one
artifact, the image, is private and for the project's own use; running one is
docs/deploy.md's subject, and cutting one is at the end of
that page.
Requires Node 26 and pnpm 11.
pnpm install && pnpm typecheck && pnpm lint && pnpm build && pnpm testpnpm devStarts both halves and watches both. Open http://127.0.0.1:5173 — that is
Vite, and on a fresh install it will ask you to create the first admin.
They remain two processes on two ports, and pnpm dev is a convenience over
that rather than a thing of its own:
| Port | What it is | On a source change | |
|---|---|---|---|
| Server | 8080 | The API, and in development nothing else | Restarts (tsx watch) |
| Client | 5173 | Vite, proxying /api to 8080 |
Hot module replacement |
The proxy is what keeps the two same-origin, so the session cookie is sent
normally and there is no CORS anywhere. In development port 8080 serves no
files — pointing a browser at it is not useful, but pointing curl at it is a
first-class way to work (docs/api.md) — and, alongside import,
the way the four kinds that have no editor yet get made.
Either half runs on its own, which is the point of keeping them separate:
pnpm dev:server # just the API, for curl-driven work
pnpm dev:client # just Vite, against a server you started some other wayIf the server is not on 8080, point the client at it —
SE_API=http://127.0.0.1:9090 pnpm dev:client. SE_CLIENT_PORT moves Vite's own
port the same way.
pnpm dev starts them in parallel and does not order them, so on a cold start
Vite is usually ready first and logs a proxy error or two until the server
binds. That is noise rather than failure. Started alone, the client comes up
fine and reports that it cannot reach the server until one is there.
pnpm build
SE_CLIENT_ROOT=packages/client/dist node packages/server/dist/main.js --data ./dataThat is the shape the container runs (docs/deploy.md), and
since P6A.1 it runs here too: the
server serves the built client from server.clientRoot and the API under
/api, on one port, with no proxy between them. The key is unset by default,
which means serve nothing — and set to a directory with no index.html in
it, the server refuses to start rather than answering the API behind a blank
page. Nothing under /api is ever the fallback: an unrouted address there
answers JSON, whatever the client directory happens to contain.
Set the four bootstrap keys in the environment when there is no config file
to set them in. SE_DATA_DIR, SE_HOST, SE_PORT and SE_CLIENT_ROOT are
the keys needed before config.json can be read — it lives inside the data
directory — and they are the only ones that take a variable
(22 §4). The file wins over the
environment, because the file is what the settings page writes.
pnpm reset-data # stop the server first — this removes the data directory
pnpm seed # a library and a playable session, through the HTTP API
pnpm dev:logged # the server alone, its log copied to ./logs/server-<date>.logpnpm seed creates the first admin if there is not one, and is idempotent —
run it twice and you get the same install rather than a second copy of it. It
does not create a connection or a binding: those carry a real key and belong to
the person rather than to a script, so a seeded install still needs one visit to
Settings before a turn will run.
pnpm dev:logged exists because pnpm dev makes the log unreadable. The
log is JSON on stdout by design (22 §4.1),
and pnpm's recursive reporter prefixes every line with packages/server dev: —
so each record becomes a string that starts with a package name and then happens
to contain JSON, which jq and everything else refuses. dev:logged runs the
server as its own process and copies stdout to a dated file as well as to the
terminal. Use it when the log is evidence; pnpm dev is fine for everything
else. Logs are not committed.
Both need a build first. The server imports @storyengine/shared through
its built entry point, so pnpm build has to have run at least once. Watch mode
covers each package's own sources — editing shared or sdk needs a
pnpm build before the server sees it.
Loopback is the default deliberately. Until an admin account exists, anyone
who can reach the port can claim the install, so LAN exposure is an explicit act
(09 §5.1). Change it in
Settings → Install, which is the surface every config key has, or with
SE_HOST before there is a file to change. Bound beyond loopback with no
admin yet, the server asks for a setup token and prints it to its own console
on every start until an admin exists — docker logs, for a container — and
keeps it at state/setup.token in the data directory. Only somebody who can
read the console or the directory can claim the install, which is exactly who
should be able to; once an admin exists the field is gone. And if there is TLS in front of it,
server.trustProxy and server.cookieSecure are the two keys that say so —
neither is derived from the bind, because a Secure cookie is not sent back
over the plain HTTP a trusted LAN is allowed to use.
config.example.json documents every key and is the other
way in — but it is not loadable as it stands. It carries // comments, JSON
does not, and a server pointed at a copy of it refuses to start with not valid
JSON. Strip the comment lines first. (The comments are the reason the file is
worth reading; the settings form is the reason it is not the main path.)
There is no email here and never will be (09 §4), so recovery is always someone with authority vouches for you. The eventual norm is an admin resetting the account in the UI; until that arrives — and forever after, for the case where no usable admin account exists — the authority is access to the host:
node packages/server/dist/main.js --reset-password ned --data ./dataIt asks for the new password on stdin (masked, twice) and exits without
starting the server. Piped input works too, for scripts and container execs:
echo "the new password" | node dist/main.js --reset-password ned. The
password is never a command-line argument, which would leak it into shell
history.
Three behaviours worth knowing: the account is re-enabled as part of the reset, since a disabled account is the same lockout in a different hat; a server already running picks the change up on the next login, no restart; and existing sessions for the account stay valid until they expire — sessions are stateless, and the reset changes the password, not the signing key.
This path enforces no minimum length, including the install's own
auth.minPasswordLength. A one-character password is accepted here, and so is
an empty one — access to the host is already the highest authority the software
recognises, and a recovery tool that argued with the person holding the machine
would only send them to edit accounts.json by hand. The one thing it refuses is
no answer at all: a pipe that closes without delivering a line — a redirect
from /dev/null, an unset variable in a script — aborts and changes nothing,
because that is silence rather than a choice. echo "" | … still sets an empty
password, deliberately.
Do not delete accounts.json to get unlocked. That destroys every account
on the install and reopens the first-boot claim window on whatever the server
is bound to.
Worth writing down, because three of the four pieces are choices rather than defaults.
pnpm devispnpm --parallelover the server and clientdevscripts — pnpm's own runner rather than aconcurrently-style dependency. Output is prefixed per package. One consequence of two processes under one terminal: a signal that does not reach the whole process group can leave a child holding a port, andPort 5173 is in useon the next start is what that looks like.- The server watches with
tsx, per 20 §11, which names it. Node 26 can strip types unaided, but this codebase imports with.jsspecifiers underNodeNextand Node will not resolve those onto the.tsfiles that actually exist;tsxdoes. It also means dev runs fromsrc/while production runs the builtdist/—pnpm --filter @storyengine/server startis unchanged and still the production entry point. - Dev writes to the repository's
data/. The scripts run with their own package as the working directory, so the server's dev script passes--data ../../dataexplicitly. Without it the data directory would appear underpackages/server/, which is not whereconfig.example.jsonor anything else expects it. - esbuild's install script is declined, in
pnpm-workspace.yaml. It arrives undertsxand is the only dependency here that asks to run one; it only re-checks a binary pnpm has already linked, sopnpm installstill runs no third-party code.
The split used to exist because the server could not serve the client's files.
It can now, and the shipped product is one container, one volume and one port
(09 §5.3) — so the split is a
development convenience rather than a gap: Vite's dev server is what gives hot
module replacement, and pnpm dev not changing was one of
P6A's exit-gate steps rather than an
assumption.
Keeping them apart in development also keeps the API honest: nothing in the server knows the client exists beyond a directory to serve, which is the same boundary the lint graph enforces in code.
| Script | What it does |
|---|---|
pnpm typecheck |
tsc -b across the project references, then the tooling |
pnpm lint |
ESLint (including the boundary graph) and Stylelint |
pnpm build |
Typecheck, emit the JSON Schemas, then the client bundle |
pnpm build:identify |
Write packages/server/build-info.json — the version and commit a release build reports. pnpm build does not run it, on purpose: a build nobody released has no identity. Delete the file after a local experiment |
pnpm test |
Vitest, every project |
pnpm test:gate |
The P1 gate alone — rebuild-from-disk equals incremental — as the named CI step |
pnpm test:fixture-pair |
The import fixture-pair project alone (import/fixture-pair.test.ts) |
pnpm test:live |
The live provider tests, against the endpoint .env names; skipped without one |
pnpm dev |
Both of the below, in parallel |
pnpm dev:server |
The API on 8080, restarting on a change (tsx watch) |
pnpm dev:client |
Vite on 5173, proxying /api to 8080 |
pnpm dev:logged |
The API alone, stdout copied to a dated file in ./logs, provider exchanges recorded to ./captures |
pnpm seed |
A known library and a playable session, over HTTP. Idempotent |
pnpm reset-data |
Removes the data directory, or removes nothing — and refuses a directory that is not one (--force overrides that check) or that holds the checkout. Stop the server first |
pnpm format |
Prettier over the code; Markdown is hand-wrapped and left alone |
pnpm format:check |
The same, checking rather than writing — what CI runs |
Run typecheck before lint on a clean clone. The boundary rules classify an
import by its resolved path, which runs through each package's built entry
point — so linting an unbuilt workspace passes for the wrong reason.
packages/shared/ portable types and schemas, and the registry over them
packages/sdk/ the published extension and mode contract
packages/server/
src/storage/ the only place that touches the filesystem
src/index-db/ the derived index and its watcher — delete it, lose nothing
src/auth/ accounts, scrypt, sessions, the setup token
src/routes/ the HTTP surface, and the client when clientRoot is set
src/turns/ the turn as a job: assembly, the provider call, the record
src/sessions/ the tree — reconstruction, snapshots, undo
src/retrieval/ lore activation, budgets, and the report that says why
src/import/ the three sources, and the sweep over a directory
src/rng/ the RNG service, and the tape a rewrite replays
packages/modes/ the built-in modes, one package each — Scene and Freeform.
scene/ each consumes the SDK exactly as a third party would, and
freeform/ may import nothing else
packages/client/ React + Vite. Play, the library, the workbench, settings,
and the actor and lorebook editors.
deploy/unraid/ the unraid template — committed, submitted to nobody
Dockerfile Alpha 1's image; with compose.yaml beside it, Tier 1
tools/ dev scripts, the build-identity writer, and release.test.ts
tools/lint-fixtures/ files that violate the day-one rules, so the rules can be
tested rather than trusted
— it exists, since P7.0, and the rules were there first, which
is still the point (20 §10). A mode package
depends on packages/modes/ does not exist yet — but the lint rules governing it do, which
is the point@storyengine/sdk and on nothing else; a reference to ../../server
would resolve, compile and quietly reverse the decision the split exists to
enforce, so the boundary graph makes it a lint failure and the single project
reference makes it a build error.
Several claims in the design documents are only true if breaking them fails the build (testing §2). Each is enforced, and each has a fixture test asserting the enforcement actually fires:
- The dependency graph.
modes → sdk, shared;client → shared;sdk → shared;server → shared, sdk. Never the other way. - No direct
fsoutsidepackages/server/src/storage, which keeps one audited path resolver the only door (20 §9). - No randomness outside the RNG service (
packages/server/src/rng/, since P2.2) — every draw goes through it and lands on the turn's tape, which is what makes a rewrite replay the same dice. Two one-file exemptions, id generation and cryptographic secrets, each argued where it is granted ineslint.config.js(20 §14.4). - Logical CSS properties only, in stylesheets and in Tailwind utility classes (20 §12.6).
- The engine names no mode. No
switchon one, no comparison against a mode id, no table keyed by one, anywhere underpackages/server/src— because an engine that knows which modes exist is an engine a third party cannot add one to (06 §2). Two layers, as with the boundary: selectors that fire in the editor, and a survey that enumerates every mode id in engine source against an allowlist with a reason on each entry. A selector cannot recognise the id of a mode nobody has written yet, which is why both exist. - An SPDX header on every source file.
AGPL-3.0-or-later, except three files that hold code from SillyTavern and Marinara Engine —
both licensed under version 3 alone — and are AGPL-3.0-only; so the program as a whole is
AGPL-3.0. THIRD_PARTY_NOTICES.md lists them and everything else
taken from elsewhere. See LICENSE, and
triage §1 for why — including why the SDK is AGPL too,
deliberately rather than incidentally.
Your characters, lorebooks and stories are yours. The licence covers this software, not the content authored with it (triage §1.2).