Built with TanStack Start and TanStack DB.

Local-first at heart, with an optional Cloudflare deployment that syncs your outline across devices via a per-user Durable Object, behind email + password accounts (Better Auth).

GitHub Stars Package mgr · Bun Language · TypeScript Bundler · Vite Tests · Playwright Hosting · Cloudflare Workers TanStack Query Better Auth React Effect Tailwind CSS Agent-friendly AGENTS.md

Status

Your outline is stored in a TanStack DB collection. By default that's backed by a per-user Cloudflare Durable Object (its colocated SQLite) through a Worker — writes go to /api/nodes, live reads over /api/sync (WebSocket) — so edits show up on your other tabs/devices without refocusing. See the sync design. The flat-row data model means swapping the backend is a collection-options change, not a rewrite.

What works:

  • Nested bullets with inline editing; collapse / expand subtrees (hover chevron in the gutter)
  • Zoom into any bullet as a temporary root (click its dot), with a breadcrumb trail back out
  • Three node kinds: bullet, task (a checkbox marks complete; a "show completed" toggle hides done items), and paragraph (reads as prose behind a paragraph glyph)
  • Drag the bullet dot to reorder and reparent in one drop (mouse + touch)
  • Markdown-style rich text: inline code, bold / italic / strikethrough / underline, highlights (six colors), [links](https://…) that fold to a clean label (pasted URLs unfurl their title), and ||spoilers|| — hidden from humans until the caret enters, redacted from AI agents
  • Clicking a #tag filters the outline in place; right-click a tag to color it
  • Daily notes: a header Today button, [[YYYY-MM-DD]] date chips that open (or create) that day's note
  • Mirrors: one node windowed into several places, every instance editable; node links ([[…]]) with backlinks on the zoomed view
  • Select whole nodes (Shift+↑/↓, the Cmd/Ctrl+A ladder) and copy / move / indent / delete the run at once
  • Bookmarks (the header star pins the current zoom view) and a Cmd/Ctrl+K command center — jump anywhere, run node + global actions
  • A / command palette (to-do, paragraph, move, mirror, formatting, …) and a move-to dialog
  • Multi-line paste lands as real nodes (and copy gives markdown back); OPML import / export speaks the Workflowy dialect
  • Dark mode, undo / redo, an in-app "What's new" changelog
  • An MCP server (/mcp, OAuth-gated) so AI agents can read and edit the outline — add to today's daily note, mirror nodes, search — with live sync into open editors

Keyboard

Key Action
Enter Split at the caret into a new sibling below (at the end of an expanded bullet, adds a child at the top instead)
Tab / Shift+Tab Indent / outdent
Cmd/Ctrl+Shift+↑ / Move the bullet among siblings; at the edge reparent into the parent's adjacent sibling
Cmd/Ctrl+↑ / Collapse / expand
Cmd/Ctrl+Enter or Cmd/Ctrl+D Toggle complete
Cmd/Ctrl+. / Cmd/Ctrl+, Zoom in / out
Backspace on an empty bullet Delete it and focus the previous one
Arrow ↑ / at line edges Move between bullets (preserves the caret column)
Cmd/Ctrl+Z / Cmd/Ctrl+Shift+Z Undo / redo
Shift+↑ / Select whole nodes (then Tab indents the run, Backspace deletes it, …)
Cmd/Ctrl+K Open the command center

Not built yet: sharing, email verification.

Stack

Layer Choice Why
Framework TanStack Start (SPA mode) File-based routing, no SSR needed for a local-first app
Data TanStack DB collections over a per-user Durable Object Optimistic mutations, schema-validated; the flat-row model swaps backends by changing collection options. Nodes ride a custom sync collection (live /api/sync WebSocket, writes via /api/nodes); plugin side data (tag colors, daily index) rides query collections over a generic /api/kv store (the sync design).
Backend Cloudflare Worker + Durable Objects One Worker serves the SPA and routes the /api/nodes + /api/kv sync APIs to a per-user Durable Object (the auth gate)
Auth Better Auth (email + password) Invite-gated signup (alpha); its user table is the identity store and user.id keys each user's DO. Sessions in D1
Validation Effect Schema Standard-schema compatible (via toStandardSchemaV1), drives the collection's item type; one schema language across client + Worker
Build Vite 8 What Start uses
Runtime Bun (dev/install) Fast; npm/pnpm/yarn work too
Observability Sentry (errors-only) Exception capture across the client, Worker, and DO — no tracing, APM, or replay, just errors. Gives contributors a real stack trace when something throws in prod. DSN is public-by-design (committed in .env.production + wrangler.jsonc, not a secret); unset in a fork = dormant, nothing phones home. Outline node text never rides an error payload.

Run it

bun install
bun run dev      # http://localhost:3000 (or next free port)

Other useful scripts:

bun run build      # production build
bun run preview    # preview the production build
bun run typecheck  # tsc --noEmit
bun run lint       # oxlint over src + worker
bun run test       # unit tests (bun test, pure logic only)
bun run test:e2e   # Playwright end-to-end tests (chromium)

Deploy

The repo deploys to Cloudflare Workers: one Worker (worker/index.ts) serves the static SPA and routes the /api/nodes + /api/kv sync APIs to a per-user Durable Object, behind Better Auth accounts (the auth gate). Config is in wrangler.jsonc. Full design: the sync design.

bun install
bun run setup        # generates BETTER_AUTH_SECRET + applies the local D1 schema
bun run dev          # starts the app (vite :3000 + worker :8787) in one command
bun run seed:user    # (optional) creates [email protected] / dotflowy-dev to sign in with

# or a production-like single-server preview
bun run cf:dev             # build + wrangler dev

# ship it
wrangler secret put BETTER_AUTH_SECRET   # once: the auth signing secret
wrangler secret put INVITE_CODES         # optional: invite codes (a signup backdoor; see Auth below)
bun run db:migrate:remote  # before the first deploy
bun run deploy             # build + wrangler deploy

The local invite code is dev-invite if you'd rather sign up your own account; bun run seed:user skips that by creating a ready-to-use account.

build:cf copies the TanStack Start shell (_shell.html) to index.html so the root and client routes (e.g. /<nodeId> zoom views) resolve through the SPA fallback.

Auth. Identity is Better Auth (email + password, with email verification), sessions in D1. Signup is openSIGNUP_OPEN="true", human-gated by Cloudflare Turnstile (TURNSTILE_SECRET_KEY + the public TURNSTILE_SITE_KEY). Leave SIGNUP_OPEN unset to fall back to invite-only, where an account needs a code from the INVITE_CODES secret or a per-email invite; both stay valid as backdoors in every state, and the public POST /api/waitlist still collects emails (viewable by admins — the ADMIN_EMAILS var — at /admin/waitlist). The static shell is public so the login screen loads; only /api/nodes + /api/kv require a session. Set BETTER_AUTH_SECRET (wrangler secret put) in prod and .dev.vars locally — without it the Worker fails closed. To carry a pre-auth outline (the constant 'default' DO) into your real account, set the OWNER_USER_ID secret to your user.id after signing up. See the auth gate.

How it works

Data model

Every bullet is one row in a single TanStack DB collection. The outline tree is reconstructed in memory at read time.

Node {
  id, parentId, prevSiblingId,        // tree shape + sibling order
  text, isTask, completed, collapsed, // content + UI state
  kind,                               // null (a bullet, or a task per isTask) or "paragraph"
  bookmarkedAt,                       // null, or the ms it was pinned (also the bookmark sort key)
  mirrorOf,                           // null, or the id of the node this one windows
  origin,                             // null if you wrote it; the agent's name if MCP created it
  createdAt, updatedAt
}

A bullet, a to-do, and a paragraph are the three kinds of node — mutually exclusive presentations of the same thing, converted between but never combined. completed is orthogonal: any node can be done. See paragraph nodes.

Sibling order is a linked list via prevSiblingId (the Workflowy/Notion approach). Inserting between two bullets is O(1) and never requires renumbering. Reordering is relinking pointers.

See src/data/tree.ts for the index builder and src/data/mutations.ts for the structural operations — insert, indent / outdent, the fused moveNode (drag reorder + reparent), move up / down, delete — plus the field setters (text, task, completed, collapsed, bookmark). Each preserves the linked-list invariant.

Why flat, not nested

A flat list of rows maps cleanly onto a sync backend. Nested JSON would force deep-merge on every keystroke. Flat rows keep moves cheap and made each backend move (localStorage → D1 → a per-user Durable Object) a nodes table swap, not a rewrite.

Persistence

nodesCollection (src/data/collection.ts) is a TanStack DB custom sync collection: on connect, the Worker routes /api/sync to the caller's Durable Object, which streams a snapshot of the outline and then live change deltas over the WebSocket — no window-focus refetch. Writes are optimistic locally (collection.insert / update / delete) and persist through the same Worker into the DO's colocated SQLite: field edits (text, completed, …) PATCH /api/nodes directly, while structural edits (insert / move / delete) land as one atomic batch (POST /api/nodes {ops}) whose optimistic overlay is held until the socket echoes the committed change. See the sync design and atomic structural writes.

Plugin side-collections (tag colors, the daily index, the changelog cursor) are query collections over a generic /api/kv store (one kv table in the same DO, namespaced by collection), reconciling on tab focus — so a custom tag color or a daily note follows you across devices too. See the sync design.

Plugins

The editor is a small core extended by plugins compiled into the bundle (an internal registry, not runtime-loaded). code, links, node-links, route-bible, tags, todos, daily, emphasis, highlight, spoiler, and provenance are each a plugin built on the same public API, so the core carries no feature-specific branches. A plugin registers against a fixed set of seams — inline tokens, delegated clicks, / commands, keymap, row slots, view transforms, autocomplete menus, paste / autoformat, and side-collections. Adding a feature is a folder under src/plugins/<name>/ plus one line in src/plugins/index.ts. See the plugin architecture.

Sync: where it stands

The collection interface is backend-agnostic — that's how moving the backend (localStorage → D1 → a per-user Durable Object) touched only collection.ts and the Worker, leaving every component and the tree logic unchanged.

Today, nodes sync live over a per-user WebSocket (/api/sync): optimistic writes still POST/PATCH/DELETE /api/nodes, and the DO broadcasts deltas to every connected tab/device. Side-collections (tag colors, daily index) still reconcile on tab focus (refetchOnWindowFocus). Not yet built:

  • Sharing — a node + subtree shared with another user (a future "mount" pointer; see the sync design).

A returning owner's pre-DO outline is carried over server-side: the Worker does a one-time, non-destructive copy of any pre-DO D1 rows into the owner's Durable Object on first /api/sync connect (ensureSeeded). There is no client-side localStorage migration — localStorage is browser-scoped but accounts are per-user, so importing it would leak one browser's leftover outline into every new account that signed in there.

See the sync design for the design and rejected alternatives (incl. why a Durable Object over D1-direct or ElectricSQL).

Agents (MCP)

The outline is also reachable by AI agents over the Model Context Protocol: point an MCP client at https://<your-deployment>/mcp and it walks the standard OAuth flow (sign in with your normal account; the client registers itself). Agents get read tools (get_outline, search_nodes, export_opml) and write tools (add_node, add_subtree, update_node, delete_node, move_nodes, add_to_today, mirror_node, mirror_to_today, import_opml); every write lands through the same atomic per-user Durable Object path as the editor, so open tabs see agent edits live. Design + rejected alternatives: the agent-native MCP server.

The OPML pair speaks the Workflowy dialect through the same shared core as the app's own import/export (ADR 0037): import_opml takes an OPML string (targeted like add_subtreeparentId, date, or the top level), lands it as one atomic batch with the agent's provenance stamp, and answers with a compact receipt (root ids, counts, the fidelity-degradation tally) — dryRun: true previews that receipt without writing; export_opml mirrors get_outline scoping and returns the raw OPML string. Both are capped at 5,000 nodes and reject rather than truncate — a full Workflowy migration belongs in the app UI.

Project layout

src/
  routes/
    __root.tsx        # HTML shell, global CSS, app-wide providers
    index.tsx         # the outline at the top level (Home)
    $nodeId.tsx       # the same outline zoomed into one bullet
  lib/
    auth-client.ts    # Better Auth browser client (useSession / signIn / signUp / signOutAndReload — sign-out always hard-navigates)
    utils.ts          # cn() and small helpers
  components/
    auth-screen.tsx     # login / signup screen (shown by the root AuthGate when signed out)
    # sign out lives in the header More menu (header-more-menu.tsx) and the Cmd+K command center (command-actions.tsx)
    OutlineEditor.tsx   # reads tree, focus + command dispatch, the zoom view
    OutlineRow.tsx      # the outline row (flat windowed list — virtualized rendering)
    inline-code.ts      # contentEditable decorate / caret engine (source-offset aware)
    menu-engine.tsx     # generic caret-autocomplete engine
    slash-menu.tsx      # the `/` command palette
    node-switcher.tsx   # Cmd+K quick-switcher
    move-dialog.tsx     # the `/move` destination picker
    bookmarks.tsx       # the header star (browsing lives in the Cmd+K empty state)
    use-drag-reorder.ts # bullet-dot drag (reorder + reparent)
    Header.tsx, paste.ts, flash-node.ts, *-provider.tsx, *-toggle.tsx, ui/
  data/
    schema.ts         # Effect Schema, Node type
    wire-schema.ts    # shared wire schemas (one leaf imported by client + Worker)
    collection.ts     # TanStack DB custom sync collection (live /api/sync + /api/nodes writes)
    realtime.ts       # the sync socket as an Effect scoped resource
    structural.ts     # runStructural: atomic structural batches + echo hold
    api.ts            # REST client for the /api/nodes Worker
    kv-api.ts         # REST client for the generic /api/kv side-collection store
    query-client.ts   # shared TanStack Query client (focus refetch = sync)
    tree.ts           # flat-list -> TreeIndex, trail / id / time helpers
    tree-store.ts     # per-node subscriptions (useNode / useVisibleChildIds)
    mutations.ts      # insert / move / delete / field setters
    history.ts        # undo / redo capture
    tags.ts, tag-colors.ts, links.ts  # pure parsing + the tag-color side-collection (synced via /api/kv)
    seed.ts           # first-run bootstrap: seed welcome bullets when the outline is empty
    sentry-scrub.ts   # shared scrub leaf: strip node text / ?q= from error payloads (client + Worker)
    useTree.ts        # useLiveQuery hook
  plugins/            # the editor's plugin layer (see docs/adr/0001-plugin-architecture.md)
    index.ts          # the one ordered array (todos, provenance, code, links, node-links, …)
    types.ts          # the typed seam contract (definePlugin)
    registry.ts       # composes every plugin's registrations once at load
    code/ links/ node-links/ route-bible/ tags/ todos/ daily/
    emphasis/ highlight/ spoiler/ provenance/   # one folder per plugin
  instrument.client.ts # Sentry init (errors-only), imported first in __root.tsx; no-op in dev/prerender
  router.tsx
  styles.css
worker/               # Cloudflare Worker: serves the SPA + routes /api/nodes + /api/kv to per-user DOs
  index.ts            #   session gate + resolveUserId + routes /api to the user's DO (own tsconfig)
  wire.ts             #   request-body schemas: the validated Worker->DO trust boundary
  auth.ts             #   createAuth(env): Better Auth (email + password + the MCP OAuth provider), sessions in D1
  outline-do.ts       #   UserOutlineDO: per-user SQLite (nodes + kv), the outline store
  mcp.ts              #   the MCP endpoint: stateless JSON-RPC over /mcp (Effect pipeline)
  mcp-tools.ts        #   the MCP tool registry (Effect Schema inputs + handlers over the user's DO)
  outline-ops.ts      #   pure server-side outline planners (snapshot -> atomic ChangeOp batch)
  sentry.ts           #   errors-only Sentry options for the Worker + DO (dormant when SENTRY_DSN is unset)
migrations/           # D1 SQL migrations (0001 nodes, 0002 kv = DO import source; 0003 Better Auth; 0004 OAuth/MCP; 0005 waitlist)
wrangler.jsonc        # Worker + assets + Durable Object + D1 bindings (+ nodejs_compat)
docs/adr/             # one ADR per load-bearing decision (history in git log)
vite.config.ts        # SPA mode + /api dev proxy

Contributing

Setup, the local dev loops, the pre-PR check matrix, and repo conventions live in CONTRIBUTING.md. Agent-facing rules are in AGENTS.md.

License

Copyright © FAITH TOOLS SOFTWARE SOLUTIONS, LLC. Released under the O'Saasy License. Source is available for learning, modification, and self-hosting; offering a competing hosted SaaS product is reserved to the copyright holder.