README.md

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.entity holds 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: from an event or epoch (inclusive), until one (exclusive), or during an 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). Only name is 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, the group_by tool).
  • 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#ref naming 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.