A small, local, SQLite-backed wiki built for Human ↔ LLM collaboration — where Claude (via Claude Desktop over MCP) and you edit the same document at the same time, seeing each other's changes live.
| Requirement | How |
|---|---|
| Python + SQLite-backed | FastAPI app; one SQLite file per wiki (data/wikis/<slug>.db) holds its pages, images, FTS index, and vectors |
| Multiple isolated wikis | Separate DB per wiki; pages, search, and [[links]] never cross |
| Themes | Swappable CSS in waikiki/static/themes/ (default / dark / sepia) |
| Images | Upload / paste / drag → stored in SQLite, served at /image/{id} |
| Extensible | Layered routes → store (repository) → db (SQLite); open-source deps |
| MCP + REST | waikiki.mcp_server (FastMCP) and /api/* share one code path |
| Markdown → HTML | markdown-it-py (GFM), [[wiki links]], Pygments code |
| Tables | GFM tables via the gfm-like preset |
| Human editing | EasyMDE editor with live preview |
| Versioning | Every save snapshots to page_versions (human / ai / collab authored) |
| BM25 / RAG | SQLite FTS5 bm25() + sqlite-vec vectors, fused with RRF |
| Real-time co-editing (CRDT) | Yjs / pycrdt room per (wiki, page); browser + Claude edit live with presence |
| AI streaming | Claude writes into the live doc via MCP; plus an optional pull-model "Generate" button |
Waikiki hosts several fully isolated wikis (e.g. Beaconlight, Crosslake,
StartupOS) — each is its own SQLite file under data/wikis/. Pages, search, and
[[wiki links]] never cross between wikis: a link resolves only within the
active wiki's own database, so contamination is structurally impossible.
- Humans switch with the wiki dropdown in the header (a per-browser cookie).
- Claude has a separate active wiki, changed only with the MCP
switch_wikitool. Every content tool refuses to run until a wiki is chosen and echoes the wiki it acted on, so the AI can never silently cross wikis. - Each agent gets its own active wiki, scoped to its MCP session. Two agents can work in two different wikis at once without moving each other, and the choice is never written to disk — so a restarted server leaves an agent with no active wiki rather than inheriting whichever one another agent last picked.
These are all independent by design — the human browsing Crosslake doesn't move
Claude, one agent doesn't move another, and vice-versa. To co-edit, ask Claude to
switch_wiki to the one you're in.
Because each wiki is a single self-contained SQLite file, you can Save one to a location you choose and Open an external wiki file back in — from the Wikis page (⚙ in the header):
- Save to file… writes a consistent
.wikisnapshot (safe even while the wiki is in use). In the desktop app this is a native Save dialog; in a browser it downloads the file. - Open wiki file… validates the file and brings it in as a new isolated wiki. Native Open dialog in the app; a file upload in a browser.
.wiki files are just SQLite, so they're easy to back up, move between machines,
or share.
- Version history: every save snapshots the page. Each article says when it last changed — Edited 3 minutes ago — 4 earlier versions you can go back to, under the tabs — and that line opens the page's History with the versions listed. (A page nobody has edited yet says so instead of pointing at an empty list.) The same place is reachable the long way round: Details → History. From there, view any version (with a diff vs current) and Restore it. Restoring is itself a save, so the text you just undid is still there. Retention keeps the last N versions per page (default 50; set per wiki in Settings, 0 = all).
- Trash (soft delete): deleting a page moves it to the Trash (header link),
hidden from lists and search but restorable. Restore brings it back;
Delete forever removes it permanently. Trashed pages are auto-purged after
N days (default 30; per wiki in Settings, 0 = never). Claude's
delete_pageis also soft; it haslist_trash/restore_pagetools. - Wiki stats: the Wikis page shows per-wiki article count, internal-link count (with broken — red-link — count), size on disk, and trash count.
Waikiki backs itself up automatically, on by default: every 24 hours it takes
a consistent snapshot of every wiki (SQLite's online backup API, so it's safe
mid-write) and keeps the last 7. They land in
<data>/backups/<YYYY-MM-DD_HHMM>/<slug>.db; the schedule, the count, and a
Back up now button are in Settings → Backups.
To restore: quit Waikiki, then Manage wikis → Open on a .db from a
backup folder. It opens as a separate wiki, so you can compare before replacing
anything. Images and version history come with it — a snapshot is a complete
copy of the wiki.
These are local copies on the same disk as your wikis: insurance against
corruption, a bad import, or a mistake — not against losing the machine. Keep
a .wiki export somewhere else too. Backups only run while the app is open, so
days you never launched it have no snapshot.
If a wiki file is damaged, the app still starts and every other wiki keeps working — each wiki is a separate file, and one bad file is not allowed to take the app down with it. The broken wiki says so where you meet it: Manage wikis lists it as can't be read, with the reason, the backups folder, and the steps to open a snapshot; visiting one of its pages explains the same thing instead of showing an error. Nothing is deleted, rewritten or "repaired" — the damaged file is your data, and it may still be recoverable. The scheduled backup skips it and still snapshots everything else.
What survives a crash, a corrupt file, a failed import or a bad edit — and what doesn't — is audited end to end, with the experiments that established each answer, in docs/data-safety.md.
your browser <--y-websocket--> [ CRDT room (pycrdt) ] <--HTTP inject-- MCP server
(EasyMDE + Yjs, presence) | (Claude Desktop)
(debounced)
v
render HTML + snapshot to SQLite + RAG reindex
- Open a page's editor and you join a CRDT room; a colored presence chip shows who else is there.
- When Claude (through the MCP
append_to_page/replace_pagetools) writes, it lands in the same room and streams into your open editor live — and a "Claude" presence chip appears while it writes. - Concurrent edits merge (CRDT), so you don't overwrite each other.
- Edits are debounced-persisted to SQLite, re-rendered to HTML, and re-embedded for search.
The old in-editor ✦ Generate button is a separate convenience: it pulls a
one-off draft from the Anthropic API (needs ANTHROPIC_API_KEY). The real
collaboration path above uses no API key — the text comes from Claude over MCP.
Data access is layered so routes stay thin and all SQL lives behind one seam:
HTTP routes / MCP tools api.py, mcp_server.py parse + validate, call the repository
▼
Repository (data access) store.py, elements.py, wikis.py, rag.py owns the SQL
▼
Infrastructure db.py SQLite connection, schema, FTS5 + sqlite-vec
▼
SQLite file per wiki data/wikis/<slug>.db
Route handlers never open a cursor and never contain SQL — they parse the
request, call a repository function (store.get_page, store.parent_of,
store.get_setting, …), and shape the response. That invariant is guarded by
tests/test_repository_chokepoint.py. This is Phase 0 of
RFC 0001; see
docs/repository-layer.md for the full rationale and
the seam where multi-tenant scoping attaches later.
Frontmatter properties are plain strings, and stay plain strings. What a
template can declare is what those strings are supposed to mean — a few
lines of name[*]: type in the template editor (str, int, float, bool,
date, list[str], or a choice like player | npc), compiled to pydantic
checks in waikiki/metaschema.py.
Pages made from such a template carry a template: property and are checked
against it on their Metadata tab and through MCP's get_metadata/set_metadata.
Checking warns; it never blocks. A wiki is a place for half-finished notes:
the write always lands, the value on disk is never rewritten (20 / 100 stays
20 / 100), and the mismatch is reported instead. A template that declares
nothing behaves exactly as it always did, and pages written before a schema
existed are untouched by it.
A page's content is a CRDT, not a string. The full encoded pycrdt Y.Doc for
every page is persisted in page_ydoc and is the source of truth; the
markdown/html columns are a projection kept alongside it for full-text
search, rendering, and RAG. Every content write goes through the repository and
advances the canonical Y.Doc, so the CRDT accumulates real history across saves
and restarts (the live co-editing room in collab.py is just an editing buffer
whose flush lands through the same seam). See waikiki/ydoc.py.
One save is one transaction: the page row, its version snapshot, its tags and the canonical Y.Doc all land together or not at all, so a crash or a power cut cannot leave the truth a revision behind the text you can see. The search index is the one thing deliberately left outside — it embeds, which can be slow, and it is a cache that can be rebuilt from the markdown at any time, so it is refreshed just after the save commits rather than while it holds the write lock. The reasoning and the measurements are in docs/data-safety.md.
Because the persisted state is a genuine CRDT, Waikiki can export a page as a
snapshot (full Y.Doc + a content-addressed image sidecar) or a changelog (updates
since a peer's state vector), and import the same from a peer — the local half
of the round-trip with the hosted platform. The format is the vendored,
version-pinned wiki_interchange library; it carries content
only (no tenancy or permissions — those are the server's), regenerates embeddings
locally on import, and rejects an incompatible spec/protocol version rather
than merging bad bytes.
A whole wiki travels as a bundle (store.export_wiki_bundle /
import_wiki_bundle): every page's Y.Doc, the page hierarchy by slug, the
manual order and starred flags, the custom elements, the templates with the
metadata schemas they declare, and one copy of each distinct image blob however
many pages embed it. It streams to and from a file — a real wiki is hundreds of
pages and tens of megabytes — and a malformed or version-incompatible bundle is
refused in full before the first local write, so a failure part-way through
leaves the wiki untouched rather than half-imported. Pages merge by slug:
existing ones are updated (and versioned) in place, and nothing local is deleted.
Hierarchy travels by slug because integer page ids are local — the same page is a
different number in every wiki.
Kahala is the shared, server-side half of the same wiki. A local wiki can be linked to one there, then pushed up or pulled down; a remote wiki can be cloned into a new local one. It's all in the Kahala pane.
Only content travels — pages, hierarchy, elements, templates and images. Tenancy and permissions are the server's and are re-attached there; embeddings are regenerated on arrival rather than shipped.
Push and pull both merge, and neither deletes. A push adds and updates pages on Kahala and removes nothing there; a pull does the same here. So a page you deleted locally is still on Kahala afterwards, and the next pull brings it back. Removing a page from both is a deliberate act on both.
Only what changed travels — a few kilobytes rather than the whole wiki — and custom elements, templates and images travel with the pages, so a page never arrives referring to a definition that didn't. If the server is running an older version, or an image doesn't survive the trip, Waikiki transfers the whole wiki instead and tells you that's what it did. Transfer the whole wiki forces it. If a transfer stops part-way — a full disk, say — it says it only partly merged rather than claiming it was refused: nothing is ever deleted, every page that arrived is an ordinary page with its history, and running the same transfer again finishes the job.
Signing in uses Kahala's own sign-in page — OpenID Connect with PKCE, no client secret, and the callback comes back to Waikiki's own loopback port. In the desktop app it opens in your web browser rather than inside Waikiki's window, so you get your usual session, your password manager and whatever second factor your organisation uses; the app notices by itself when you're done. The refresh token goes in your Keychain, never into a wiki file (that file is what "Save wiki" hands over) and never into a plain config file. Where there's no secure store, signing in is switched off and says so rather than falling back to writing the token somewhere less safe. The link itself — the address and the remote wiki's name — is recorded outside the wiki too, so it can't travel to someone you share the wiki with.
Someone who joins over LAN sharing can't reach any of it. An agent connected over MCP can push and pull, but can't link, clone or sign in — choosing where a wiki gets sent is yours.
Full detail, including every refusal and why redirects are never followed, in docs/kahala-sync.md.
cd ~/localdev/waikiki
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txtFirst run downloads the default embedding model (fastembed BGE-small, ONNX,
~130 MB) and warms it at startup. No PyTorch required.
Embedding models are managed in the UI — Settings → Embedding model → paste
a HuggingFace slug and click Add model. It downloads, becomes active, and all
pages are re-embedded (vector-dimension changes handled automatically). The
fastembed provider (ONNX, default) needs no PyTorch; the local provider
(any sentence-transformers slug) needs the optional requirements-local.txt.
python run.py
# → http://127.0.0.1:8787Waikiki ships as a native .app (a WKWebView window over the local server) — see
Releases. Or build it yourself:
bash scripts/build_macos.sh
# → dist/Waikiki.app (+ dist/Waikiki-macos.zip)The app is currently unsigned, so on first launch macOS Gatekeeper will warn: right-click the app → Open → Open to allow it (once). Signing/notarization is a later step.
The packaged app stores its data in ~/Library/Application Support/Waikiki
(not inside the bundle), so it survives moving/replacing the app. Override with
WAIKIKI_DATA.
Settings → Updates checks GitHub for a newer release and can install it: the app backs up every wiki, downloads the release, verifies it, then quits and relaunches itself to finish. Your content lives outside the bundle, so an update replaces code only.
Because the app carries no Apple identity, it can't rely on macOS to tell a real release from a tampered one — so every release zip is Ed25519-signed and the app verifies it against a public key pinned at build time, before unpacking. Anything unsigned, wrongly signed, or modified is refused, and a build with no pinned key disables updating rather than trusting the download.
Checks are check-only and hourly at most; installing is always an explicit click,
since the swap restarts the app. Cutting a signed release is
./scripts/release.sh v0.14.0 — see docs/updates.md for
the trust model, key handling, and failure modes.
Waikiki shells out to tools it doesn't ship — the claude/gemini CLIs, an
image CLI, cloudflared, Ollama. Settings → Capabilities says which of them
this machine actually has, what each one powers, and what is missing, before
you press anything. Chat, ✦ Generate and the editor's ✨ image button render
greyed out and linked to that view when they can't work, instead of failing a
minute after the click.
Where something can be fixed, there's a button, not a command to copy — and
it offers the step that is actually possible: without npm, "install the Claude
Code CLI" becomes "install Node.js first", and with no Homebrew either it becomes
instructions with a link rather than a button that would fail. Installing asks
first, says what it is about to do, and reports honestly when it doesn't work
(npm i -g failing on permissions is common, and reads as exactly that).
Where nobody publishes an install path, Waikiki says so and points at the setting you can change — it won't guess at a command. Doorman is listed but never offered as a fix: it is your app, and Waikiki never installs or starts it. The one installer that pipes a download into a shell (Google's Antigravity CLI, the default image tool) names the host it fetches from before it runs. See docs/capabilities.md.
If you also run Doorman, Waikiki will notice and use it for things it does better:
- Speech — its voices for Listen and "Say this word".
- Generation, chat and images — the agents and image models you already configured there, instead of a second set of API keys and CLIs here. Whatever answers is named in the editor's status line, under the chat reply, and in Settings → Doorman, so you always know which model wrote something.
Each of those is capability-probed, not version-sniffed: an older Doorman without them simply keeps Waikiki on its own Anthropic/Ollama/CLI path, silently.
It is optional in every direction: Waikiki never starts or installs Doorman, everything works identically without it, and you can decline the integration even while Doorman is running (Settings → Doorman). The one exception is Waikiki displayed inside Doorman's own window, where "optional" has no meaning — there the setting is shown locked, with the reason. See docs/doorman.md.
waikiki:// URLs open the app at a specific place, and survive the app picking a
different port (which an http://127.0.0.1:8787 link doesn't):
waikiki://beaconlight/meru # a page
waikiki://beaconlight/meru#abilities # a section
waikiki://beaconlight # a wiki's front page
waikiki://beaconlight?q=clockwork # search, inside that wiki
waikiki:// # the front page
Page options → Copy link copies one, and MCP get_page returns the same
thing as link — so an agent can hand you something you can actually open.
The wiki is the authority, so there are no reserved verbs to shadow a wiki named
search or home. A URL scheme is an input anything on the machine can fire, and
the app window has owner rights, so parsing is a strict allow-list: every slug
validated, paths constructed rather than echoed, search always scoped to the
validated wiki. See
docs/deep-links.md. Deep links work in the packaged
.app only — a source run has no Info.plist for macOS to route through.
A calendar element can show events typed into the page, subscribe to a real
calendar, or both:
```calendar
ics: https://calendar.google.com/calendar/ical/.../basic.ics
tz: America/New_York
events: [{"title":"Troop 63 Campout","start":"2026-09-11","end":"2026-09-13","link":"troop-63-campout"}]
```
The ics address keeps itself current; the events list holds the entries that
link to pages in this wiki, which a subscription can't do. To add or change a
calendar, edit the ics: line — in Google Calendar the address is Settings →
your calendar → Integrate calendar → Secret address in iCal format. Nothing else
to configure.
Waikiki fetches the calendar server-side because no provider allows a web page to
read one (there are no CORS headers on any of them), via
/api/calendar-feed?url=…. Since the page chooses that URL, the allow-list is
the only thing keeping this from being an open proxy: any web page in your
browser can call our loopback port, so the route accepts nothing but https at a
known calendar host — otherwise a website could aim it at your router or a
metadata endpoint. Note the trade: the address sits in page content, and a Google
secret address reads the whole calendar, so it is visible to anyone who can read
the page.
Recurring events are expanded properly — repeat rules, deleted occurrences, and single occurrences that were moved — and placed on the day they fall on where you are, so an 8pm event doesn't show up on tomorrow. If the calendar host can't be reached the element says so rather than rendering an empty month, which would claim nothing is scheduled. See docs/calendar-feeds.md.
An agent that hits a bug or a limitation in Waikiki can write it down with
report_bug while it still has the failing call in hand. Reports are queued,
not sent: they appear under Reports from agents (the menu entry shows up only
when something is waiting), and Open on GitHub opens GitHub's own new-issue
form pre-filled, so nothing is published until you submit it there.
That indirection is deliberate. Filing directly would mean Waikiki holding a GitHub token — and, more to the point, a report written at the moment something failed often quotes the page it was working on. Reading it first is what keeps your wiki's content off a public tracker.
Easiest: open Waikiki and click Connect Claude in the header (or visit
/help). That page shows a config pre-filled with this install's real paths
and a copy button — paste it into Claude Desktop's config and you're done.
The config file lives at
~/Library/Application Support/Claude/claude_desktop_config.json (create it if
it doesn't exist). Keep Waikiki running, paste the config, then fully quit and
reopen Claude Desktop (⌘Q). Two forms:
A) Packaged app — Claude Desktop launches the .app itself in MCP mode
(no source checkout needed). Replace the path with where your app lives:
{
"mcpServers": {
"waikiki": {
"command": "/Applications/Waikiki.app/Contents/MacOS/Waikiki",
"env": {
"WAIKIKI_MCP": "1",
"WAIKIKI_DATA": "/Users/YOU/Library/Application Support/Waikiki",
"WAIKIKI_WEB_URL": "http://127.0.0.1:8787"
}
}
}
}B) Running from source — launch the venv Python:
{
"mcpServers": {
"waikiki": {
"command": "/Users/YOU/localdev/waikiki/.venv/bin/python",
"args": ["-m", "waikiki.mcp_server"],
"env": {
"PYTHONPATH": "/Users/YOU/localdev/waikiki",
"WAIKIKI_DATA": "/Users/YOU/localdev/waikiki/data",
"WAIKIKI_WEB_URL": "http://127.0.0.1:8787"
}
}
}
}
WAIKIKI_DATAmust match the data directory the running app uses, so the MCP server and the window share the same wikis. The in-app Help page fills this in for you.
Then, in Claude: "list the waikiki wikis and switch to Beaconlight." Claude
must switch_wiki before it can read or write — this is what keeps wikis from
mixing. Open a page's editor and ask Claude to add a section; watch it type in
beside you.
MCP tools: list_wikis, current_wiki, switch_wiki, create_wiki (wiki
selection — required first), then list_pages (top-level by default, like the
sidebar; children=true for the whole wiki, children=["a-parent"] for one
branch — and it always reports how many sub-pages it withheld, so an agent never
reads silence as absence), get_page (returns a heading outline plus its
resolved outbound links — target, title, the label the reader sees, and
whether the page exists, plus a one-line hint asking the agent to read the
linked pages it is about to rely on), read_pages (the same payload for up to 10
slugs in one call, so following those links costs one round-trip instead of ten —
slugs that don't exist come back in missing rather than failing the batch, and
anything past the cap in dropped), create_page, and a family of merge-safe
live edit tools:
edit_page (find/replace — preferred), replace_section, insert_after /
insert_before, prepend_to_page, remove_from_page, append_to_page, and
replace_page (full rewrite, last resort). Plus changes_since (change feed),
backlinks, broken_links, delete_page (to trash), list_trash,
restore_page, and search (hybrid RAG) — all scoped to the active wiki.
Templates get the same read-before-you-write treatment: get_template returns a
template's markdown and metadata schema, and edit_template replaces one exact
snippet of it. Without those, changing a single line of a template meant
rebuilding the body from a page it produced and overwriting everything that page
didn't happen to show.
| Method | Path | |
|---|---|---|
| GET | /api/pages |
list |
| POST | /api/pages |
{title, markdown} |
| GET/PUT/DELETE | /api/pages/{slug} |
get / update / delete |
| GET | /api/search?q=&k= |
hybrid BM25+vector RAG |
| POST | /api/images |
multipart upload |
| POST | /api/collab/{slug}/append · /replace |
inject a live edit (used by MCP) |
| GET | /api/collab/{slug}/live |
current live (unsaved) markdown |
| POST | /api/ai/stream |
SSE token stream (pull-model Generate button) |
| GET | /api/calendar-feed?url=&tz= |
events from a subscribed calendar (allow-listed hosts only) |
Interactive docs at /docs. Websocket sync at ws://host/collab/{wiki}/{slug}.
REST/collab requests select the wiki via the X-Waikiki-Wiki header (default:
the registry's default wiki).
- sqlite-vec loads via apsw (bundles a SQLite with loadable-extension support, which stock CPython often lacks). If unavailable, search degrades to BM25-only and everything else still works.
- Switching the embedder in Settings changes the vector dimension, so the vector index is rebuilt and all pages re-embedded automatically.
- Collaborative editing loads Yjs (
yjs,y-websocket,y-codemirror) from esm.sh and EasyMDE from unpkg — an internet connection is needed for the editor libs (they can be vendored locally later). The read view, REST, and MCP work fully offline.
Elastic License 2.0 — source-available, not open source.
You can read it, run it, modify it, and redistribute it. The one substantive restriction is that you may not offer Waikiki to third parties as a hosted or managed service. Use it yourself, inside your company, on your own machines, freely.
The vendored wiki_interchange package (waikiki/vendor/) is first-party and
covered by the same license — see docs/vendoring.md.