- TypeScript 92%
- CSS 8%
- HTML <1%
- Docker <1%
StoryForge #
A writer's story bible — the people, places, ships, creatures and everything else in a story, how they change over the story's timeline, and the writer's notes — kept in atproto permissioned spaces (proposal 0016). The sibling of Cavu: Cavu is where a book is published and read; StoryForge is where it's planned, drafted and checked. An assistant can work in it too, through StoryForge's MCP server.
The model #
Nothing in a story is assumed static. So:
- Entities are bare identities. A character, a place, a ship, a giant
bird, a species —
page.cavu.forge.entityholds only an id and a kind, and the kind is any word the story needs. - Everything said about an entity is a fact (
page.cavu.forge.fact) — its name included. A fact has an attribute, a text value and/or a target (another entity), and a place on the timeline:froman event or epoch (inclusive),untilone (exclusive), orduringan epoch. A later fact replaces an earlier one for the same attribute, unless the attribute is multi-valued. So "Lucy" can become "Lady Cassia" at the Reckoning without anyone editing the first fact. - The vocabulary is the project's own (
page.cavu.forge.attribute). Onlynameis built in. A science-fiction project with a genderless species simply never defines "gender"; an age-of-sail fantasy defines "captain", "home port", "species". A definition says whether values accumulate and whether they point at entities — one that does is a relation (allegiance, sails under), with an optional name for the other side ("crew", "members"). Any attribute can group entities at a point or across an epoch (the Group by view, thegroup_bytool). - The timeline is events in story order (
page.cavu.forge.event) — when things happen, not when they're told, ordered by fractional index so a move rewrites one record. There are no dates; ages are stated facts. - Epochs are sets of events (events of kind
epoch): books, years, eras. An event can sit in several (Year 412 and Book One), a book can jump around in time, a year can span books, and epochs can belong to epochs. Each membership can carry the epoch's own telling order. - Notes (
page.cavu.forge.note) are the writer's, about the book. - Linked books: a project's story record links any number of published Cavu books, and a book can be linked from any number of projects.
src/lib/facts.ts answers "what was true when" — the state of an entity
at any point, and where on the timeline something held ("when was LC
11?") — for the web app and the MCP server alike.
Where the data lives #
- One space per project:
at://<your did>/space/page.cavu.forge.project/<tid>. You're the space authority, so your own PDS is both repo host and space host. Private by default (member-list policy, no members); nothing in a space reaches the public firehose. - Built to share: every link is a
page.cavu.forge.defs#refnaming its repo, so collaborators can later write facts into their own repos in the same space.
The lexicons are canonical in ../publishing/lexicons/page/cavu/forge/ —
the directory Cavu's reader/scripts/publish-lexicons.ts mirrors to the
network — and src/lib/types.ts follows them by hand. Publish them before
using a real PDS: spaces PDSes check a space's collections against the
published space lexicon (Vivarium doesn't).
The MCP server #
server/ is one Node server: the built web app, the atproto client
documents, an MCP authorization server (dynamic client registration, PKCE,
rotating refresh tokens) chained to atproto OAuth on the writer's PDS, and
a stateless Streamable HTTP MCP endpoint at /mcp. Add
https://<host>/mcp as a custom connector in Claude (web, desktop or
phone); signing in asks for your handle, then sends you to your own PDS to
approve.
Tools address everything by id — list_entities, list_timeline,
list_epochs, list_notes, list_attributes give the ids — and include
get_entity (state at any point, full history), when, group_by
("everyone by allegiance at the Reckoning", "by who they sail under during
Book Two"), add_fact,
define_attribute, create_event / create_epoch / set_telling_order,
and link_book / read_book_index / read_chapter, which read published
books through Cavu's public markdown export (/{did}/{rkey}/md).
Locally the server is a loopback atproto client (the client library only
accepts https discoverable client ids); deployed it's a confidential
client and needs FORGE_OAUTH_KEY (an ES256 private JWK) and PUBLIC_URL.
See server/config.ts for every setting.
Live #
https://forge.cavu.page — Fly app cavu-forge (org cavu, region ewr,
one always-on machine, SQLite on the forge_data volume). The Claude
connector URL is https://forge.cavu.page/mcp.
fly deploy --app cavu-forge # from this directory
Secrets: FORGE_OAUTH_KEY, made with npx tsx scripts/gen-oauth-key.ts
and piped straight into fly secrets set — never pasted. DNS: forge A/AAAA
records in the cavu.page zone (Marque, the at.marque.dns/cavu.page
record in the pardy.at repo).
Running it #
Against a local Vivarium:
cd ../vivarium && bun run dev # http://localhost:2580
cd ../publishing/reader && pnpm dev # Cavu on :3000, for linked books
pnpm install
pnpm dev:vivarium # the web app, http://127.0.0.1:5180
pnpm server:vivarium # the MCP server, http://127.0.0.1:5181/mcp
Sign in with any handle Vivarium knows (its consent screen is passwordless):
curl -X POST localhost:2580/xrpc/localhost.vivarium.createAccount \
-H 'content-type: application/json' -d '{"handle":"writer.vivarium.test"}'
pnpm seed:vivarium creates writer.vivarium.test (if missing) and a
filled-in demo project, "Wind Road" — re-run it after restarting Vivarium,
which keeps nothing. pnpm test runs the unit tests; pnpm test:vivarium runs a project's
whole lifecycle against Vivarium without a browser. Use
http://127.0.0.1, not localhost — atproto loopback OAuth clients must
redirect to an IP literal.