Guidance for coding agents working in this repo.
http://localhost:6747— Vite dev server (frontend). (cd browser && pnpm dev)http://localhost:9883— local AtomicServer. (cd server && cargo run)
The frontend auto-updates via HMR. If changes don't appear, reload the page. If you edit @tomic/lib or @tomic/react, those packages may need a rebuild first.
A new git worktree has no browser/node_modules, no built dist/ and no
WASM; none of that is tracked. Set it up with:
cd browser && pnpm install && pnpm run -r build
cd ../server && cargo build
That is all; no workarounds needed. The build includes the WASM, and the
first run compiles wasm-pack into this worktree's own .bin/ (about a
minute). Don't borrow the main checkout's wasm-pack or public/wasm/, and
don't build @tomic/lib separately first: pnpm run -r build orders the
packages itself. The Website runtime bundles (search-view.html,
website-runtime.min.js) are gitignored and regenerated by vite, so builds
leave git status clean. Node 22 (CI) through 26 work, vitest included.
The pre-commit hook lints and clippies a snapshot of the staged files. It
borrows this worktree's node_modules (so pnpm install must have run) and
skips the frontend build, so it needs nothing else.
Use the ./planning folder to write plans and keep track of progress.
Use todo lists and checkboxes to track progress.
Make sure to update the planning as you find new insights and see outdated planning text.
Remove planning documents that are completed.
Default branch is develop. Open PRs against it. Staging deploys from develop
after green CI; production and live docs deploy from a stable v* tag. Do not
introduce a main branch, treat master as production, or add a job that
fast-forwards main when a tag is pushed — the tag is the release. Hotfixes
branch from the tag and merge back to develop. See CONTRIBUTING.md.
Stop the vite dev server before any git operation that rewrites files — a
rebase, a branch switch, a git checkout -- .. See below for why.
Add labels to every GitHub issue you create or triage. Reuse the repo's existing labels (for example the type of issue and the area it touches) rather than inventing new ones.
Every PR that changes the UI needs screenshots of the actual screen. Run the app, open each changed view (Playwright or a browser), and capture it; include mobile width when layout is affected. A text description of a UI change is not enough for review.
Never commit screenshots to the repository, not even temporarily or in a
planning/ folder. Share them in one of these ways instead:
- Attach them to a GitHub PR comment or the PR description (GitHub hosts the image, the repo stays clean).
- Upload them to external storage, such as an S3 bucket, and link them from the PR.
- Show them in chat to the developer you are working with.
Wuchale extracts these from source as vite serves it, so the dev server is a second writer on tracked files. Four ways that has cost real time:
- During a rebase. The extractor rewrote the
.pofiles between steps, so git reported "You must edit all merge conflicts" whilegit diffshowed nothing unmerged. Stop vite first. - Two writers. Two dev servers, or a dev server plus a build, race on the
same catalogs and corrupt them. Symptom:
[i18n-404:NNN]where a label should be. One vite at a time — a worktree has its own catalogs, so one per worktree is fine. - Catalogs that are not the extractor's fixed point. If what is committed
differs from what extraction produces, every dev-server start rewrites them,
vite sees a file change, and the page reloads. That is not cosmetic: a reload
abandons in-flight work, and this silently cancelled a destroy mid-test. If
git statusshows.pochurn on a clean checkout, settle them and commit. - The compiled catalogs are a second artifact. Wuchale compiles the
.pofiles intosrc/locales/.wuchale/, which is gitignored.git checkout -- src/locales/resets the.pofiles and leaves the compiled output from another state; the two then disagree and every indexed string shifts. Missing entries show as[i18n-404:NNN]; shifted entries show a different real string, so nothing looks wrong and tests fail on the wrong element. Whenever you reset the.pofiles, alsorm -rf browser/data-browser/src/locales/.wuchaleand restart vite. Do not make the.pofiles read-only to stop the rewrite; the compiled output goes stale instead. - Settle with
pnpm clean-translations.wuchale.config.jsignores test files, which the vite plugin never sees, so the CLI and the dev server now extract the same catalog. After adding or removing UI text, run it inbrowser/data-browserand commit the.pofiles. A component whose strings never made it into the catalogs renders them blank in a production build, and only e2e notices. Check the diff doesn't drop real translations.
Strings a model reads — tool descriptions, prompts, instructions — must be
@wc-ignored. A translated tool name is not a tool name. See
browser/data-browser/CLAUDE.md for the ignore syntax.
After touching anything that renders text, read the .po diff by hand: a
guarded element can silently drop sibling strings, and entries re-key when an
icon moves into a message.
Use the Charlotte MCP server and navigate to http://localhost:6747/app/dev-drive to instantly create a fresh agent.
In E2E tests, most specs use test.beforeEach(before) from test-utils.ts, which calls devDrive(page) and gives every test a fresh agent + drive. For a second browser context signed in as the same user, use getDevDriveSecret(page) after before has run. Call devDrive(page) directly only when a spec does not use the shared before hook.
- Operate the app at
localhost:6747for quick iterations on react code. - Start every session by navigating to
http://localhost:6747/app/dev-driveto get a clean, authenticated state. - If the app shows
UnauthorizedorSomething went wrong, navigate to/app/dev-driveto fix it.
- Identify the bug, where it's coming from.
- Reproduce the bug in a test at the cheapest layer that can fail: Rust /
vitest first, then
browser/lib*.integration.test.ts(real server, no UI), then one Playwright test. E2E is the most expensive. Tag@smoke(smokeinbrowser/e2e/tests/test-utils.ts) only if a failure means the first-hour demo is dead; extra operators and offline variants stay in the full suite. Seeplanning/e2e-light-heavy.md. - After reproduction in a failing test, fix the bug until the test and all other tests are green again
In dev mode, window.devtools exposes diagnostics for inspecting a resource across every persistence layer. Run devtools.help() for the list. Most useful:
| call | what it does |
|---|---|
devtools.inspect(subject?) |
JS store + WASM/OPFS + server HTTP GET, side-by-side. Defaults to the URL's ?subject= (or current drive). |
devtools.opfsList(prefix?) |
Subjects in the WASM DB (default prefix did:ad:) |
devtools.wsLog(n?) |
console.table of the last N commit log entries |
devtools.problems() |
Resources currently loading, errored, or new |
devtools.forcePut(subject) |
Re-serialize a JS-store resource into OPFS with round-trip verification |
Source: browser/data-browser/src/helpers/devtools.ts.
Atomic Server is a graph database with real-time sync, built on Loro CRDT for conflict-free collaborative editing.
Naming boundary: Atomic Place (atomic.place) is the workspace product;
AtomicServer is its self-hostable server component. Optional managed accounts,
billing and signaling are implemented in the internal sibling atomic-saas
repository. Keep that repository's implementation details out of public FOSS
copy unless they explain an integration boundary.
docs(docs) — Public-facing Atomic Data spec and product documentation. Describes how the protocol works, very important.planning(planning/) — Internal design notes and larger technical direction. Readplanning/README.mdand the relevant plan before broad architectural work.atomic_lib(lib/) — Core library powering atomic-server + WASM / OPFS browser storage.atomic-server(server/) — Actix-web HTTP/WS server. Usesatomic_lib(KV full-text search + optional LanceDB vector search).@tomic/lib(browser/lib/) — TypeScript client library, powering the other JS projects@tomic/react(browser/react/) — React hooks.data-browser(browser/data-browser/) — The web app (React + TipTap + Loro), feels similar to notion. See the related AGENTS.mdflutter/— Cross-platform canvas app (Android/iOS/Web). Usesflutter_rust_bridgeto callatomic_lib. Seeflutter/README.mdandflutter/AGENTS.md.
- Resource = property-value pairs with a Subject URL, backed by a Loro CRDT document.
- Commit = a signed mutation containing
loroUpdate(base64 Loro binary). - Agent = Ed25519 keypair, identified by
did:ad:agent:{publicKey}. - Drive = top-level container resource.
Loro is the sole state management engine. The old set/remove/push commit fields are deprecated and rejected by the server.
resource.set(prop, value)→ writes to LoroDoc's"properties"map + sets_dirtyresource.save()→exportLoroDelta()→ base64 → commitloroUpdate→ sign → POST- Incoming WS commits:
execLoroUpdateCommit()imports Loro binary into resource's LoroDoc, materializes properties into propvals
- Commit arrives at
/commit apply_changes()importsloroUpdateinto resource's LoroDocimport_update_with_diff()computes add/remove atoms for search indexingloro_value_to_atomic_value_tagged()materializes Loro values to AtomicValuetypes, using thedatatypesmap- Loro snapshot stored alongside PropVals for future merges
The LoroDoc has two sibling root maps:
properties—property URL → value. Loro primitives stored directly (strings, numbers, booleans); arrays as nativeLoroLists; objects as JSON strings.datatypes— sparseproperty URL → tag, recording the datatype only where a bare primitive is ambiguous in a load-bearing way. Tags:atomicUrl,resourceArray,json,resource. Scalars and plain/cosmetic strings carry no entry. Written byset_property(Rust) andResource.writeDatatypeTagsat sign time (TS).
Materialization prefers the tag: loro_value_to_atomic_value_tagged() recovers
the exact Value variant from it. Untagged values fall back to the
loro_value_to_atomic_value() heuristic (URL-shaped strings → AtomicUrl,
{...} → NestedResource), kept for legacy / not-yet-tagged docs. Cosmetic
datatypes (markdown/slug/date/uri, timestamp) are deliberately not
tagged — they collapse to string/integer; the Property's datatype stays
authoritative. See planning/loro-source-of-truth.md.
When editing a resource, load the existing Loro snapshot first, then edit on top. Creating a fresh LoroDoc for each edit causes LWW conflicts. The CommitBuilder on the server converts set/remove to Loro at sign time via sign_at().
{
"https://atomicdata.dev/properties/subject": "did:ad:{genesis}",
"https://atomicdata.dev/properties/signer": "did:ad:agent:{publicKey}",
"https://atomicdata.dev/properties/loroUpdate": "base64...",
"https://atomicdata.dev/properties/signature": "base64...",
"https://atomicdata.dev/properties/createdAt": 1775504552928,
"https://atomicdata.dev/properties/previousCommit": "did:ad:commit:{sig}",
"https://atomicdata.dev/properties/isGenesis": true
}loroUpdateis a plain base64 string (not a{type, data}object)set,push,removeare rejected by the server- Signature: deterministic JSON-AD (sorted keys, minified, no
@id, no signature field) - Genesis commits:
subjectexcluded from signed bytes (derived from signature)
Subject is an enum: Internal (internal:/path), External (https://...), Did (did:ad:{genesis}).
Commit.subject and Commit.signer are Subject, not String.
Equality is by URL string only — drive_hint and subdomain don't affect identity (custom PartialEq/Hash).
Binary frames [tag][payload] (lib/src/sync/protocol.rs, mirrored in
browser/lib/src/ws-v2.ts; spec in docs/src/websockets.md):
| Frame | Direction | Purpose |
|---|---|---|
AUTH (0x01) |
C→S | Auth, answered by AUTH_OK / ERROR |
SUB (0x20) |
C→S | Commit notifications for a drive/resource |
UPDATE (0x11) |
S→C | Applied commit (Loro delta or snapshot) |
COMMIT (0x13) |
C→S | Persist a signed commit |
SYNC (0x30) family |
Both | Drive reconcile |
EPHEMERAL (0x40) |
Both | Edits in progress, cursors, presence |
LORO_SYNC_SUBSCRIBE {json} |
C→S | Text: register for a resource's ephemera |
PRESENCE_SUBSCRIBE {json} |
C→S | Text: register for a drive's presence |
Pattern: Subscribe to broadcast BEFORE sending a message that expects a response.
Uses ed25519-dalek (pure Rust, WASM-compatible). Server keeps ring for TLS only.
pub struct Resource {
propvals: PropVals, // Read cache
subject: Subject,
commit: CommitBuilder, // Legacy server-side
loro: Option<AtomicLoroDoc>, // CRDT doc, lazy
}save()— server-side (CommitBuilder → Loro → apply locally)save_remote(store)— client-side (propvals → Loro → export → sign → HTTP POST)save_as_genesis(store)— DID resource, subject =did:ad:{signature}
TipTap + loro-prosemirror (LoroSyncPlugin, LoroUndoPlugin, LoroEphemeralCursorPlugin).
Real-time: useLoroSync hook → EPHEMERAL (kind DOC) over WebSocket.
Loro OpLog time-travel: doc.getAllChanges() → sort → doc.checkout(frontiers) per version. Instant, no network round-trips.
Devices sync via Iroh QUIC connections. The transport is in lib/src/sync/:
peer.rs— Iroh endpoint, Router (must stay alive for incoming connections), persistent NodeID (secret key stored in redb), known peers list.engine.rs— Transport-agnostic sync engine. Compares Loro version vectors, computes diffs, imports snapshots. Used by both WS and Iroh.protocol.rs— Binary frame encoding: AUTH, SYNC, SYNC_DIFF, SYNC_PUSH, SYNC_OK, GET, UPDATE.
- Both devices start Iroh (
peer::start()) → get persistent NodeID, connect to n0 relay - Device A shows QR code containing
did:ad:node:<nodeId> - Device B scans QR → calls
peer_sync(nodeId)→sync_drive_with_peer() - B→A: AUTH, SYNC (with B's version vectors)
- A→B: SYNC_DIFF (what to push/pull), SYNC_PUSH (A's data)
- B→A: SYNC_PUSH (B's data for A's pull list)
- Both devices now have each other's data
- The
Routermust be kept alive globally (ROUTERstatic) — dropping it stops incoming connections. - After sending the final SYNC_PUSH, call
send.finish()+ short delay so the server processes it before the connection drops. - Loro snapshots are stored in
Tree::LoroSnapshotskeyed bySubject::pure_id()(strips query params/drive hints). collect_drive_subjects()andbuild_drive_vvs()must usepure_id()consistently to match snapshot keys.
did:ad:node:<hex>— URI format for Iroh NodeIDs, used in QR codes and UI.- NodeIDs are persistent — derived from a secret key stored in redb (
Tree::PluginMeta). - Known peers are also stored in
Tree::PluginMetaas a JSON array.
TESTING_COVERAGE.md maps which flows are tested at
which layer, and — more usefully — which are not. Read it before deciding where
a new test belongs, and update it when you add one or discover a gap.
cargo test -p atomic_lib --features db-redb --lib # unit tests (needs the `db` feature)
cargo test -p atomic-server --lib
cargo test -p atomic-server --test sync # integration test: real server, 2 agents, WS sync
cargo test -p atomic_lib --features "iroh,discovery,db-redb" --lib -- sync::tests # Iroh sync tests (incl. live sync)
cargo test -p atomic_lib --features "iroh,db-redb" --lib -- sync::iroh_e2e -- --test-threads=1 # Iroh e2e: bulk + live + folderId
cargo test -p atomic_lib --features db-redb,iroh --test identity_durability # identity/peers survive an unclean kill
cargo test -p atomic_lib --features db-redb,iroh --test cross_process_sync # two OS processes reconcile over Iroh
cargo test -p atomic-server --test it iroh_pairing # two servers pair via POST /iroh-sync
cargo test --manifest-path flutter/rust/Cargo.toml # Flutter bridge (workspace-excluded, needs --manifest-path)
cd browser/lib && pnpm test # JS unit tests
cd browser && pnpm run -r build # Full workspace build
cd browser && pnpm run test-e2e:light # Playwright @smoke (local diagnostic)
cd browser && pnpm run test-e2e # Full Playwright suite (batch / develop / tags)
When you restart an atomic-server for tests, gate on curl returning HTTP
200, never on the port: redb's lock outlives the listening socket by several
seconds, so an immediate restart exits with "Database already open" and the
suite runs against nothing, failing in ways that look like product bugs. Retry
the spawn when the log says so, and give a fresh server about ten seconds
before the first spec.
atomic_lib's unit tests need the db feature — hierarchy.rs's test module
calls Db::init_temp and test_utils::setup_test_env, both gated behind it. So
cargo test -p atomic_lib --no-default-features does not compile; don't
reach for it. CI never trips on this because it tests the whole workspace at
once (.dagger rustTest), where atomic-server's unconditional atomic_lib
dependency (features = ["db-redb", ...] in server/Cargo.toml) turns db on
via feature unification. --lib is load-bearing too: the list_sled_trees
example needs sled, which only db-sled provides.
The startup update script only runs pnpm install (in browser/). Everything below is
already handled in the VM snapshot; these notes capture the non-obvious gotchas for
building/running the stack again after pulling changes.
browser/data-browser/vite.config.ts defaults VITE_ATOMIC_SERVER_URL and points the
Vite proxy at the same value, so the app and the proxy always agree with each other. The
server has to be started on that port too, which is 9883 by default:
/workspace/target/debug/atomic-server --port 9883 # subsequent runs
/workspace/target/debug/atomic-server --port 9883 --initialize # first run / to reset the /setup invite
If the three disagree, the app silently repoints drives to a server that isn't listening
and auth/drive resolution fails. Then open http://localhost:6747/app/dev-drive for a
clean authenticated agent + drive.
A wrapper that runs its own node overrides the app's port by exporting
VITE_ATOMIC_SERVER_URL (vite prefers a VITE_-prefixed process variable over the env
files), and starts its node there instead. Check what the dev server printed before
assuming 9883.
data-browser's build:wasm uses CARGO_ENCODED_RUSTFLAGS=$'--cfg\x1f...' (bash ANSI-C
quoting). The VM's /bin/sh is dash, which doesn't understand $'...', so pnpm scripts
must run under bash. This is configured once (persisted in ~/.config/pnpm/rc):
pnpm config set script-shell /usr/bin/bash
If pnpm build:wasm ever fails with error: multiple input filenames provided (... $--cfg\x1f...),
re-run that config command.
browser/data-browser/public/wasm/{atomic_wasm.js,atomic_wasm_bg.wasm} are git-ignored and
must be generated (needs the wasm32-unknown-unknown target, already installed):
pnpm --filter @tomic/data-browser build:wasm
Only re-run this when the wasm/ or lib/ Rust changes; it is not part of pnpm start.
This does not apply to cargo run in server/: server/build.rs also watches
wasm/src and lib/src (in addition to browser/data-browser/src, browser/lib/src,
browser/react/src, and a few config files) and reruns the JS/WASM build when any of
them are newer than dist. It's only the Vite dev server (pnpm start) that skips the
WASM build entirely, so a wasm/ or lib/ change there still needs
pnpm --filter @tomic/data-browser build:wasm run by hand.
cd browser && pnpm start runs @tomic/lib + @tomic/react (tsup watch) and the Vite dev
server (:6747) together. During vite dev you may see React Compiler warnings from
oxc-transform-react (try/catch, finally, ref-during-render, and similar
Rules of React violations). Those are non-fatal: the compiler skips
auto-memoizing that component and the app still serves and HMRs normally.
| Service | Dir | Dev command | Port |
|---|---|---|---|
| AtomicServer (Rust: HTTP/WS, redb, Loro sync) | server/ |
cargo run -- --port 9885 |
9885 |
Frontend (Vite) + @tomic/lib/@tomic/react watch |
browser/ |
pnpm start |
6747 |