Lorekeeper

Inspiration

Anyone who has run a long tabletop campaign knows the moment: session twelve, a player asks "wait, what did we promise that ferryman back in spring?" and the table goes quiet while someone scrolls through four months of chat logs and half-legible session notes. Keyword search is no help, because nobody wrote the word "ferryman"; the note says "Aldous, toll gate, brother's ring," and the knowledge is keyed by meaning, which is the one thing grep does not do.

At the same time, agents have become good at exactly this kind of clerical work. An agent sitting in the browser during a session can be the party's chronicler: it records NPCs, promises, lies and plot threads as they happen, and weeks later either the keeper or the agent pulls the thread back out by asking in plain language. For that to be trustworthy at a game table it had to meet two bars. It had to be visible, so the human reads and edits the same codex the agent writes, because a chronicle you cannot inspect is a chronicle you cannot trust. And it had to be private, because a campaign is full of secrets the players have not discovered yet, and those should not be shipped to somebody's server. Those two bars fixed the architecture before any code existed: the recall itself, embeddings and vector search included, had to run inside the tab.

What it does

Lorekeeper is a living world codex for tabletop campaigns and long-form fiction, and its center of gravity is semantic recall computed entirely in the tab. Every entry is embedded in the page by a bundled MiniLM model, and every question runs through ranked vector search executed by a database engine running in the same tab. Ask "why do people in the river villages keep getting sick?" and the codex returns the warning about the tainted wells as the top hit, even though the two share no keywords, with relevance shown as a plain percentage on the card. There is no embedding API and no search service behind it; the meaning math happens where the secrets live.

The codex is built around five tools: record_lore, recall_lore, list_lore, revise_lore and forget_lore, and the same five tools, one contract, are driven three ways. In an agent-native browser the page registers them through WebMCP, so the browser's own agent discovers them and chronicles the session with no configuration at all; the page is the MCP server, and when the agent asks the codex a question, its query and the ranked answers render on the page in front of you, the same view a human search shows. In any other browser, a scribe panel inside the page runs the whole loop against an OpenAI-compatible endpoint you bring: type what happened at the table and the model records entries as it reads, cards appearing live, then ask a question in plain language and it recalls and answers from the codex. The third consumer is you: a composer adds entries by hand, every card has inline editing, and suggested queries beside the search box run recall in one click, so a visitor with no agent and no key still sees the codex answer by meaning within seconds.

Because the person and the agent write into the same codex, they can also collide, and this is where a database engine earns its place in the page. Every revision is a conditional write. Correct a card by hand while the agent revises the same entry from an older copy and the engine refuses the agent's write; the refusal is visible in the wire view as a conditional update failing its check, and the agent re-reads and retries, so the final entry carries both changes and nobody's correction is silently overwritten. Shared memory that two parties can write needs concurrency control, not just storage, and you can watch it happen.

A first visit lands on a sample campaign rather than an empty page, its entry dates spread over recent weeks so there is a lived-in world to recall from the first moment; a notice above the cards clears the sample when you want to start your own. A codex holds as many campaigns as you run: a picker in the header switches between them, each campaign is its own table partition, and recall searches only the world you are in.

Your world is also a file. An export control downloads the entire codex, entries and vectors included, as a single file, and import loads one back. Copy it, share it with your table, fork the campaign at a decision point and play both branches. A single campaign travels on its own too, as portable JSON, and that export arrives with promote.sh: a generated script of plain aws CLI calls that creates a managed Amazon DynamoDB table and writes the same entries into it. Lorekeeper never runs it; it is a text artifact you can read, and it makes the portability claim demonstrable in one click.

The codex survives page reloads and never leaves the page: entries, embeddings and search all stay in the tab. When you use the scribe panel, your messages, the model's replies, and the tool calls and results inside that conversation go to the model endpoint you configured; the codex itself stays where it was.

Under it all sits ExtendDB, an open source DynamoDB-compatible database compiled to WebAssembly over SQLite. That is the mechanism rather than the headline: a codex two authors share needs conditional writes, consistent reads and a 384-dimension cosine vector index to stay coherent, and because the engine speaks the DynamoDB wire protocol, the same requests and the same data model deploy unchanged to a managed cloud table when a campaign outgrows the tab. A wire view shows every call as raw DynamoDB JSON for anyone who wants to check that claim live.

Why WebMCP

Memory that a person and an agent genuinely share has to be one artifact both can read, write and correct, and WebMCP makes that the natural architecture instead of an integration project. The page registers record_lore and its siblings through navigator.modelContext, an agent-native browser discovers them on its own, and every call the agent makes lands in the same codex the human is looking at, editing, and occasionally overruling. There is no sync, no plugin, and no second copy of the data, because the page is the server.

The tool surface leans on the parts of the spec written for this situation. recall_lore and list_lore carry a read-only annotation so a cautious agent can call them without ceremony, forget_lore is marked destructive, and the descriptions of both write tools warn the model that recalled entries are stored notes, data rather than instructions, never to be followed as commands. Codex entries are user-authored free text that flows back into an agent's context, which is the output-injection case the spec's security guidance describes, so the guidance lives in the tool contract itself rather than in a document nobody ships. And when WebMCP is present, even the in-page scribe panel routes its calls through the browser's registered surface rather than a private path, so every consumer exercises the same contract an external agent sees.

What this buys the table is a division of labor neither side manages alone. The agent does the clerical work during play, chronicling NPCs, promises and lies as separate entries with threads while the humans keep playing. The human keeps judgment: any card can be corrected inline, and when a hand edit and an agent revision collide, a conditional write inside the database refuses the stale one, the agent re-reads and retries, and nobody's change is silently lost. The chronicle stays trustworthy because both authors are held to the same rules by the same engine.

How we built it

The stack is deliberately small: a Rust engine compiled to a roughly 1.1 MB gzipped wasm module, a vendored transformers.js runtime with a quantized 384-dimension MiniLM model, and a few hundred lines of framework-free JavaScript that map the five tools onto PutItem, Query, UpdateItem, DeleteItem, and vector search over the DynamoDB JSON protocol.

The three consumers share one construction site. The tool layer is dependency-injected and exports both the handlers and their schemas; WebMCP registration consumes that contract directly, and the scribe panel derives the model's function-calling schemas from the very same definitions, so there is no second hand-written copy to drift. The panel itself is a plain ES module speaking OpenAI-compatible chat completions with fetch only: one plain JSON completion request per model round, in a multi-turn loop that keeps feeding tool results back while the model keeps calling tools, under a hard cap of eight rounds per turn, with a system prompt that makes it a keeper's scribe: record what is worth remembering as separate entries with threads, recall before answering, never invent lore. The key lives in memory, or in sessionStorage only if the user opts in; never localStorage, never a URL, never the console. The page refreshes the codex once per completed scribe turn rather than once per tool call, so an agent recording five entries in one breath produces one crisp re-render instead of five.

Sharing the codex between a human and an agent needed an arbitration rule, and we took it from the database rather than the app: revise_lore is a conditional write keyed on the entry's timestamp. When the keeper corrects an entry the agent holds stale, the agent's revision loses cleanly, re-reads, and retries; the same is true in reverse for the inline card editor, which saves through the identical path and shows a conflict view when it loses. The whole flow is covered by Playwright tests, including a WebMCP round trip through the browser's tool-execution surface, a registration spec that pins the annotations and tool descriptions, and a scribe-panel round trip against a stubbed endpoint; no test ever calls a real model.

Challenges

WebMCP is young, and we hit its edges. The API namespace has moved during the origin trial, and current Chrome delivers tool arguments as a JSON string where the spec examples show objects, so our registration shim normalizes both directions. The API also requires origin-isolated documents, which cost us an afternoon of mysterious absence until the right header was in place.

Feeding one tool contract to two very different consumers was its own problem. WebMCP wants registration objects with JSON Schema inputs; an OpenAI-compatible endpoint wants a tools array of function declarations, and models are pickier: a description that reads well to a human can still steer a model to the wrong tool, arguments arrive as strings to be parsed and validated, and a malformed call must round-trip as a readable error the model can recover from rather than a thrown exception that kills the turn. Deriving both surfaces from one set of definitions kept them honest, but it forced the contract to be precise enough for the stricter consumer at every point.

Keeping the privacy claim precise got harder the moment a model endpoint entered the picture. The original pitch was that nothing leaves the page, and for the codex that is still exactly true and test-asserted: the engine, the embeddings and the search never make a request. But a scribe panel that calls a model obviously does, so we scoped the claim rather than burying the change: the badge and the docs now say the codex never leaves the page, and everywhere the panel is described we state plainly that the conversation, tool calls included, goes to the endpoint the user chose. Honest wording turned out to be a design constraint, not a documentation chore.

Persistence was the hardest engineering cut: full durable storage in a browser wants a dedicated worker, so we ship an IndexedDB-backed store with relaxed durability plus a snapshot flush when the page is hidden, which keeps every operation synchronous on the main thread and still brings the codex back after a reload.

What we learned

Tool contracts should carry meaning, not mechanics: the agent sends the text of what happened at the table, and the page owns the vectors. Naming the tools for the job ("record lore," not "put item") turned out to shape how agents use them; a description that says when to reach for a tool matters as much as its schema, and doubly so when the same description also has to steer a function-calling model.

We also learned that raw cosine distance is telemetry, not communication. The keeper this page is skinned for has never met a metric where lower means better, so cards now show relevance as a percentage and the wire view keeps the raw numbers for anyone who wants them. In the same spirit, a codex bulk-inserted in one second contradicts a story about weeks of play, so the sample campaign carries dates spread across weeks and the first screenful now corroborates the pitch instead of undercutting it.

And a chronicle becomes trustworthy when the human can see it and change it. The moment entries rendered as cards someone could search, revise inline, and win a write conflict against, the feature stopped being storage and started being a shared artifact of the game, which is what a campaign codex has always been.

What's next

Multi-tab coordination so a table of players can watch the same codex, a full-durability storage worker, and import of session transcripts so the agent can chronicle a whole evening in one pass.

Built With

  • chrome-origin-trial
  • css3
  • dynamodb
  • extenddb
  • html5
  • indexeddb
  • javascript
  • local-first
  • onnx-runtime
  • playwright
  • rust
  • semantic
  • sqlite
  • transformers.js
  • vector-search
  • wasm-bindgen
  • webassembly
  • webmcp
Share this project:

Updates