Eigen is a self-hosted Google Workspace alternative. Monorepo with integrated apps sharing a single API server, UI library, and business logic layer.
- Runtime: Bun (server + client)
- Backend: Elysia + Drizzle ORM (SQLite)
- Frontend: React 19 + TypeScript + TanStack Router + TanStack Query + Eden Treaty (type-safe API client)
- UI: Tailwind CSS 4 + shadcn/ui + Lucide React
- Auth: better-auth (email/password, 2FA, orgs, teams)
- Real-time: Yjs (collab editing), WebSocket, SSE (notifications)
apps/
api/ # Elysia backend (port 8000)
mail/ # Email client
drive/ # File storage
docs/ # Document editor (Yjs/Tiptap)
contacts/ # Contact management
calendar/ # Calendar + scheduling
chat/ # Real-time chat (MUD-inspired)
stickies/ # Kanban board (Yjs)
slides/ # Presentations (Yjs)
sheets/ # Spreadsheets (sheet engine + Yjs)
space/ # Team workspace
admin/ # Org/team admin + first-run setup wizard
index/ # Landing page
packages/
lib/ # @workspace/lib — shared types, hooks, API client, SSE handlers, validation
ui/ # @workspace/ui — shared shadcn components, layout system
sheet/ # Spreadsheet engine + UI (forked from fortune-sheet/luckysheet)
data/ # Runtime storage (databases, user files)
docs/ # Architecture documentation
bun run serve # All apps + API
bun serve:mail # Single app + API
bun run lint # Lint + format check (biome)
bun run lint:fix # Auto-fix lint + format issues
bun run typecheck # Type check all packages
bun run test # API integration tests
bun run check # lint + typecheck + test- No AI co-author trailers in commits — never add
Co-authored-by: Claude/Copilot/...lines to commit messages, even if your tooling defaults to it - Read CODE-STANDARDS.md before writing code — defines typing rules, code style, common LLM mistakes with BAD/GOOD examples, and the self-review checklist. Must be followed before declaring any task complete
- Read existing code before writing new code — read 2-3 existing files in the same directory. Match their style, structure, naming, and patterns exactly. New code must look like it was always there
- Search SHARED-PRIMITIVES.md before building a shared hook, component, type, or
util — the generated index of everything
packages/ui+packages/libexport. Import what already exists; if it's missing, export it from the package barrel so it gets catalogued.bun run primitivesregenerates it - Read relevant docs before planning or coding — check
docs/for architecture docs on the domain you're touching (e.g.,docs/COMMENTS.mdbefore adding comment features,docs/EXPORT.mdbefore changing export). Don't assume you know the conventions — verify them - Always run
bun run checkafter changes (lint + typecheck + test). When multiple agents run in parallel, only the main agent should run check — concurrent runs cause deadlocks - Code goes in the right layer — hooks/mutations in
packages/lib/src/core/[domain]/hooks/, shared types inpackages/lib/src/types/, shared UI inpackages/ui/, app-specific code inapps/. Rule of thumb: if two or more apps need it, it belongs inpackages/. Never putuseQuery,useMutation, error toasts, ortry/catch+toast.error()in app components — all error handling lives in hooks usingonMutationError. See NOTIFICATIONS.md - Package dependency direction is one-way:
sheet → lib, never the reverse.packages/libis shared FE+BE;packages/sheethas React peer dependencies and DOM-coupled modules. If lib imported sheet, the BE would transitively pull React in at module-eval time. Shared sheet types (Cell,Sheet,Op,CellMatrix,Range,SingleRange,ConditionalFormatRule, …) live inpackages/lib/src/sheets/types.ts; the sheet package'sengine/types.tsandstate/types.tsre-export them. Sheet utilities that need to be importable by both FE and BE (e.g.opToPatchOnSheets) live inpackages/lib/src/sheets/ - Don't break the type chain — types flow from Elysia route handlers → Eden Treaty → hooks → components
automatically. No
as any, noas Typecasts. Fix types at the source (add return type annotations to backend handlers using shared types frompackages/lib/src/types/). See CODE-STANDARDS.md § Typing - Backend errors use
ApiError—throw new ApiError(status, message)for HTTP errors.throw new Error()only for internal invariants (db not open, missing config) - Think about every
await— a bare async call returns a truthy Promise (dangerous in conditionals:if (!asyncFn())is always false). Fire-and-forget must have.catch(). Skipawaitwhen blocking would hurt response time and failure is acceptable - Sanitize user-provided paths — validate against
..,/, and control characters before filesystem or header use. Never interpolate raw user input into HTTP headers - Check existing code before writing new — shared components in
packages/ui/src/components/layout/(TooltipButton,DeleteDialog,EmptyState, etc.), utilities inpackages/lib/(cn(),formatDate, shared types). Don't reinvent them. See LAYOUT.md - One source of truth per fact — a set, map, schema, or constant that answers a question (which extensions are text-editable? which MIME is a spreadsheet? what's a valid S3 config?) lives in exactly one module. Import it; never re-list its members inline "just for here." Two lists of one fact drift (we shipped three disagreeing "is this text?" registries). Need a subset? Derive it from the canonical one
- A primitive isn't "shared" until its barrel exports it — reusable values go through the package's
public entry (
@workspace/ui,@workspace/lib/<domain>), reusable types through@workspace/lib/types/<domain>. An unexported primitive is invisible to the next author, who rebuilds it. The inverse is also a smell: deep-importing past a barrel (@workspace/lib/core/…) usually means the thing you reached for should have been exported - Fix broken windows — fix pre-existing issues if the fix is straightforward
- Self-review before declaring done — review your diff against the checklist in CODE-STANDARDS.md
- Keep docs up to date — update
docs/and this file when changes affect architecture
The standard way to run feature work, proven over the sheets xlsx-fidelity program (cycles 0–8) and the 2026-07 sheet-package cleanup program. Scale it to the job: a one-line fix needs none of this, a feature needs most of it, a program of changes needs all of it.
- Evidence first, spec sign-off before code — start multi-step work with an audit pass on
real data (a gap matrix with measured counts, not assumptions), then a written spec with
explicit decision points, and get sign-off BEFORE implementing. Local-only artifacts (specs,
audits, verification screenshots) live in gitignored
docs/superpowers/. - Own branch per unit of work — merge
--no-ffto main only when verified; never push without an explicit go. If the main checkout is busy (another session), work from a git worktree. - TDD, red first — a failing test before the implementation, always. Tests pin the CONTRACT (full round-trips, output-level assertions), never library internals; they are the committed regression net.
- Delegate to subagents with complete briefs — keep the controller context small. The
orchestrating session plans, briefs, reviews results, and merges; implementation, review,
and browser verification each run in their OWN subagent so no single context drowns in file
dumps. Every brief includes: required reading (this file +
docs/CODE-STANDARDS.md+ 2–3 sibling files in the target dir), exact file pointers and encodings, scoped test commands, and the hard rules — stay strictly in scope, diagnose failures by reading source (never blind-retry), nevergit push. - Independent review before merge — a reviewer that did NOT write the code, in two stages: spec compliance first, then quality (bugs, edge cases, conventions), held to the Review Standard below.
- Real-world verification is mandatory — drive the running dev app headless against REAL documents with a throwaway test user, and read the screenshots: verdicts come from pixels plus behavioral probes (scroll, click, reload-persistence), not from data-shape assertions alone. Data pipelines verify as closed round-trips with feature counts; pure refactors are pixel-gated (byte-identical screenshots before/after); files consumed by external software (xlsx, ics, eml, …) get spot-opened in the real consumer. Full recipe — test-user conventions, auth cookie injection, upload/convert API, HMR workarounds — in VERIFICATION.md.
- Simplify pass after — review the whole diff from four angles (reuse, simplification, efficiency, altitude), apply what's worth it, and re-gate with the same tests/pixels. Per-step reviews catch local issues; this pass catches cross-cutting drift.
- Docs in the same cycle — update the domain doc and any status/backlog before calling the work done; record accepted drifts and out-of-scope decisions where the next session will look for them.
- Design changes go in small rounds — one or two visual changes at a time, screenshots first, a human verdict before merge.
Applies to every reviewer — subagent, external LLM, or a developer driving one. The goal of review is code that is clean, easy to read and understand, simple, and stable — and consistent: in patterns, naming, comment density, how solutions are implemented, and how the UX behaves, the change must be indistinguishable from the code around it.
- Brief reviewers cold and unopinionated. A reviewer reads this file and CODE-STANDARDS.md itself and judges against those written standards, not personal taste. Give it the task and the files — never the author's narrative of what was already "addressed" or "accepted": inherited framing is how half-fixed problems survive review.
- Review the blast radius, not the diff. Trace the changed behavior through code the diff did not touch — other apps' entry points, SSE handlers, callers and callees of every changed function. Most escaped bugs live in files the diff never opened.
- Hunt absences, not only mistakes. A diff review can only judge code that exists. For every new seam ask: every cache → who invalidates it (including SSE)? every route → what validates and bounds its input, and before which side effects? every fan-out or loop → what bounds it? every check-then-create → what closes the race? every "fixed" class of bug → are ALL its instances fixed, or only the one that was reviewed?
- Every touched file comes out better. Fix broken windows. Remove dead code and duplication.
Replace hand-rolled logic with the shared components and helpers
(SHARED-PRIMITIVES.md). No over-engineering. Types come from
packages/lib/src/types/[domain].ts— never redefined, never declared at the wrong layer. - Signal over volume, both ways. Report only genuinely real findings — "clean" is a valid verdict — but before merge run one recall-biased pass with the absence checklist above: a cold reviewer that over-reports and gets pruned beats a polite one that misses. (2026-07: an outside reviewer found ten real issues on a twice-reviewed branch; nearly all were absences, half-fixed classes, or bugs outside the diff's blast radius.)
| Concept | Location | Pattern |
|---|---|---|
| Home singleton | apps/api/src/lib/home/home.ts |
Per-user instance managing DB connections + domain services. Subclasses: UserHome, TeamHome, OrgHome |
| Domain classes | apps/api/src/lib/[domain]/[domain].ts |
Business logic (Drive, Mail, Contacts, Calendar, ChatRoom) |
| Routes | apps/api/src/routes/[domain].ts |
Thin Elysia routers, {auth: true} for protected |
| DB schemas | apps/api/src/lib/[domain]/schema.ts |
Drizzle ORM schemas |
| DB config | apps/api/src/lib/[domain]/db-config.ts |
DatabaseConfig with versioned migrations |
| ManagedDatabase | apps/api/src/lib/core/managed-database.ts |
WAL mode, versioning, auto-sync, dirty tracking |
| Collab storage | apps/api/src/lib/collab/ |
Yjs updates/snapshots in data.db BLOBs; zstd-compressed at the storage seam (blob-codec.ts), BC via zstd magic-byte sniff; live WS sync unaffected |
| Storage backends | apps/api/src/lib/storage/ |
Two classes — LocalStorage (serves both local + local-key modes) and S3Storage |
| Errors | apps/api/src/lib/core/errors.ts |
throw new ApiError(status, message) |
| SSE emission | apps/api/src/lib/[domain]/sse-events.ts |
home.broadcast(buildEvent(...)) |
| Notifications | apps/api/src/lib/notification-center/ |
home.notifications.persist({...}) — per-user SQLite, broadcasts SSE; typed details + coalesce. See NOTIFICATION-CENTER.md + ACTIVITY-ROWS.md |
| Auth | apps/api/src/lib/auth/auth.ts |
better-auth with org/team/2FA/API key plugins |
| Protocol auth | apps/api/src/lib/auth/protocol-auth.ts |
verifyProtocolAuth() — shared IMAP/CalDAV/WebDAV auth (app password → primary password fallback) |
| WebDAV | apps/api/src/lib/webdav/ |
RFC 4918 Class 1+2 server at /webdav/:ownerId/:mountId/*; mirrors CalDAV layer |
| Server config | apps/api/src/lib/config/server-config.ts |
Identity + secrets written once at setup (domain, orgName, orgId, secret, setupCompleted) |
| Server settings | apps/api/src/lib/config/server-settings.ts |
Runtime-adjustable quotas, storage default (defaults.mount.{storageType,s3Config}), onboarding, guests |
| Quota resolution | apps/api/src/lib/config/quota.ts |
resolveUserQuotas() — server default + team overrides (most permissive wins) |
| Quota enforcement | apps/api/src/lib/config/enforcement.ts |
getUploadMaxSize, enforceAvatarUpload |
| Mailer | apps/api/src/lib/core/mailer.ts |
sendMail(OutboundMail) — sendmail transport, skips in dev + demo mode, supports replyTo/attachments |
| Environment | apps/api/src/lib/config/env.ts |
isProduction() (PRODUCTION=1/NODE_ENV=production); isDemo() (EIGEN_DEMO=1) — demo-instance deployment shape, see DEMO_MODE.md |
| Singleton factory | apps/api/src/utils/singleton.ts |
createAsyncSingleton() for Home/DB instances |
| Home relay | apps/api/src/lib/home/home-relay.ts |
Cross-home messaging via sendToHome(); reads via pull*(). See SCALABILITY.md |
| Scheduler | apps/api/src/lib/scheduler/ |
scheduleInterval(name, ms, fn) for in-process periodic jobs; register in jobs.ts |
| Upload pipeline | apps/api/src/lib/mount/upload-queue.ts + lib/sync/ |
Write-behind S3 uploads: isRemote mounts stage + enqueue in metadata.db, a per-mount UploadQueue drains with retry/backoff; local/local-key stay synchronous. See SYNC.md |
| Versioning | apps/api/src/lib/versioning/ |
Opt-in file-level snapshots in <container>/versions/; snapshot/restore mechanics + locking in STORAGE.md § File Versioning |
| Copy / move | apps/api/src/lib/drive/copy-across.ts |
Move stays in-mount; copy picks the same-storage fast path or the cross-mount bridge, containers copy safely by design. See STORAGE.md § Copy / Move |
| File history + watch | apps/api/src/lib/drive/history.ts |
FileHistory on Mount (file_events + path_watchers): typed events when an actor is threaded, read-gated watcher notifications via home-relay, shared describeFileEvent phrasing, live refresh via drive:file-history-updated SSE. See PROPOSAL_FILE_HISTORY.md (phase 1 + as-built deltas) and ACTIVITY-ROWS.md |
The Drive system has four layers. When adding new features, all four need changes:
Route (thin handler) → SharedDrive (ACL wrapper) → Drive (business logic) → Mount (storage + DB)
- Mount (
apps/api/src/lib/mount/mount.ts): Core storage operations on a single mount'smetadata.db. Handles file CRUD, path resolution, storage key building. Three storage backends:local(hierarchical paths),local-key(flat UUID keys),s3(S3-compatible). Trash, copy, search-index and the managed document-DB lifecycle live in siblingmount/*.tsmodules (plain functions over the mount —Mountstays the facade); versioning mechanics inversioning/snapshot.ts - Drive (
apps/api/src/lib/drive/drive.ts): High-level API over multiple mounts. Handles ACL propagation, collab document lifecycle, SSE emission, sharing - SharedDrive (
apps/api/src/lib/drive/sharedDrive.ts): ACL-enforcing wrapper, composition over inheritance — does NOT extend Drive.getSharedDrive()returnsDrive | SharedDrive; routes can only call methods present on both, so adding a public method toDrivewithout a matchingSharedDrivewrapper is a TS error at the callsite. Own-drive routes get raw Drive (no ACL overhead); cross-owner routes get SharedDrive (ACL-checked). Escape hatch: a small number of routes (/shared/by-me,/shared/with-me) need owner-only Drive methods that have no meaningful ACL semantics. TheyrequireSelf(params.ownerId, user.id)first and then callgetDrive(user)to obtain raw Drive — bypassing the SharedDrive surface. The drive.ts class doc explains which methods are non-route-callable (annotated// Called by:— invoked by peer lib code like collab/chat/home-relay, not from routes). If you add a route that needs one of those, add a SharedDrive wrapper first, don't reach for the escape hatch - Routes (
apps/api/src/routes/drive.ts): Thin Elysia handlers that delegate viagetSharedDrive
| Concept | Location | Pattern |
|---|---|---|
| API client | packages/lib/src/core/api.ts |
Eden Treaty — type-safe from Elysia definitions |
| Data hooks | packages/lib/src/core/[domain]/hooks/ |
TanStack Query with hierarchical query keys |
| SSE handlers | packages/lib/src/core/[domain]/sse-handlers.ts |
Invalidate query cache on events |
| Shared types | packages/lib/src/types/[domain].ts |
Used by both FE and BE |
| Validation | packages/lib/src/validation/ |
Shared FE/BE validation |
| Colors | packages/lib/src/constants/colors.ts |
EIGEN_COLORS, EIGEN_ACCENT_COLORS |
| Yjs utilities | packages/lib/src/core/collab/yjs-utils.ts |
restoreYjsDoc — server-side only (used by CollabDocument.applySnapshotState during version restore to replace the live Y.Doc's declared roots inside one transaction, so connected editors converge with no reload). Handles Y.Map, Y.Array, Y.Text, and Y.XmlFragment/Tiptap, with arbitrary nesting |
| App shell | packages/ui/src/components/layout/app/app-shell.tsx |
Wraps every app (Topbar + sidebar + content) |
| Provider stack | packages/ui/src/components/layout/app/eigen-app.tsx |
Auth → SSE → Upload → Preview → CommandPalette → Toaster |
| Layout | packages/ui/src/components/layout/app/column-layout.tsx |
ColumnLayout + Column with responsive mobile switching |
| Routing | apps/[name]/src/routes/ |
TanStack Router, file-based. _auth.tsx guards |
| Command palette | packages/lib/src/core/command-palette/ + packages/ui/src/components/layout/app/command-palette/ |
Mod+K dialog mounted by AppShell.PaletteRunner, gated via useOptionalCommandPalette so apps without the provider don't crash. See PROPOSAL_COMMAND_PALETTE.md; the doc: scope handoff is in IN_DOCUMENT_SEARCH.md |
| In-document search | packages/lib/src/doc-search/ + packages/ui/src/components/layout/search/ + per-app controllers |
⌘F find bar (+ replace) in every eigendoc editor — one shared 3-method DocSearchController contract, DocSearchProvider session + keybinds, ?q= deep links, phase-2 comment search. See IN_DOCUMENT_SEARCH.md |
| Search | apps/api/src/lib/mount/search-index.ts + apps/api/src/routes/search.ts + packages/lib/src/core/search/ |
Per-scope inline FTS5 — mail (MailDB.searchMail) + drive name/content indexes with a per-mount reindex queue; FE useSearch hook. See PROPOSAL_SEARCH.md |
| Contact suggestions | packages/lib/src/core/contacts/hooks/use-contact-suggestions.ts |
Single canonical hook merging personal contacts + team members, used by mail compose, calendar share/attendees, drive share, chat @-mention, and the command palette. ContactSuggestion shape in packages/lib/src/types/contact.ts |
| New-chat wizard | packages/lib/src/core/chat/hooks/use-chat.ts + packages/ui/src/components/layout/chat/chat-create-wizard.tsx |
ChatCreateWizard — two-step "New chat" dialog (person + team mode) with open-don't-duplicate matching and server-side create + share. See CHAT.md |
| Mail shortcuts | apps/mail/src/components/mail/hooks/use-mail-shortcuts.ts |
Opt-in Gmail-style keyboard shortcuts; ? in Mail opens the cheat-sheet. See MAIL.md |
| Mail list + pagination | packages/lib/src/core/mail/hooks/use-emails.ts + apps/mail/src/components/mail/ |
Keyset-paginated useInfiniteQuery with optimistic per-id cache patches; BE serves the DB immediately and reconciles via fire-and-forget sync. See MAIL.md |
| Eigen-doc icons | packages/lib/src/core/eigendoc-icons.ts |
EIGEN_DOC_ICONS: Record<EigenDocType, LucideIcon> — single source for the icon shown next to a doc/sheets/slides/stickies/chat row. Kept out of types/drive.ts so that file stays type-only on the BE side |
| Drive copy/move | packages/lib/src/core/drive/hooks/use-drive.ts |
Right-click Move to… / Copy to… / Duplicate via useMovePath/useCopyPath/useDuplicatePath + the reused DriveLocationPicker. See STORAGE.md § Copy / Move |
Every page uses ColumnLayout + Column. The toolbar is a separate prop, not part of the page content.
The Column renders the toolbar in a fixed h-12 bar with px-4 border-b. This ensures consistent
toolbar height across all pages.
<ColumnLayout mobileColumn={showDetail ? 'detail' : 'list'}>
<Column id="list" width="flex" onBack="sidebar" toolbar={<MyToolbar />}>
<MyContent />
</Column>
<Column id="detail" width="400px" onBack={handleBack} toolbar={<DetailToolbar />}>
<DetailContent />
</Column>
</ColumnLayout>The first column passes onBack="sidebar" — the sentinel renders the mobile back arrow that steps
up to the sidebar column, and self-gates away when the app has no sidebar. Without it a mobile user
has no path back to navigation. Use width="flex" for a single full-width column. For a plain page title in the toolbar, use the shared
ToolbarTitle component (@workspace/ui/components/layout/toolbar), which applies the .eigen-toolbar-title
class (text-sm font-normal text-foreground truncate — thin, matching the breadcrumb) rather than
hand-rolling a styled span. Richer toolbars (drive's path) compose a BreadcrumbPage (font-normal), so
the two read at the same weight.
To show action icons only on row hover (like the share icon in Drive), use the Tailwind group +
invisible group-hover:visible pattern:
<TableRow className="eigen-list-item group">
<TableCell>
<span>Item name</span>
<div className="invisible group-hover:visible ml-auto">
<TooltipButton icon={Edit} tooltipText="Edit" className="h-7 w-7" onClick={...} />
</div>
</TableCell>
</TableRow>Use TooltipButton from packages/ui/src/components/layout/toolbar/tooltip-button.tsx for icon buttons
with tooltips. Don't rebuild Tooltip+Button manually.
If hover icons would affect row height, use absolute positioning so they float over the row.
Before building custom UI, check these exist in packages/ui/src/components/layout/:
| Component | File | Use for |
|---|---|---|
TooltipButton |
toolbar/tooltip-button.tsx |
Icon button with tooltip |
DeleteDialog |
delete/delete-dialog.tsx |
Destructive action confirmation |
ConfirmDialog |
confirm-dialog.tsx |
Generic confirmation dialog |
EmptyState |
app/empty-state.tsx |
"Nothing here" message with icon |
LoadingState |
app/loading-state.tsx |
Centered spinner |
ErrorState |
app/error-state.tsx |
Error message display |
SearchBar |
search-bar/search-bar.tsx |
Search input with icon |
FileMenu |
toolbar/file-menu.tsx |
File dropdown (rename, delete, etc.) |
RequestAccessView |
app/request-access-view.tsx |
"Request access" screen for shared resources (hides sidebar) |
Full component list: LAYOUT.md
See CODE-STANDARDS.md for the canonical driveKeys example. Query keys must
always include ownerId — see Common Pitfalls below. Export invalidation functions for use in SSE
handlers + mutation onSuccess.
These patterns have caused bugs across multiple domains:
- Query keys must include
ownerIdfor any owner-scoped data. Without it, switching between personal and team contexts serves stale cached data from the wrong owner - Add a
SharedDrivewrapper for every route-callableDrivemethod —getSharedDrivereturnsDrive | SharedDrive, so aDrivemethod without a matchingSharedDrivemethod is unreachable from routes (TS error). Add the wrapper with the appropriate permission check (withReadPermission,withWritePermission, or owner check) - MIME type strings must match the Eigen File Types table exactly — use the constants, don't type them by hand.
eigenslidesnoteigenslide,eigensheetsnoteigensheet validateSearchin shared routes must extract all URL params the route uses — missing params (likeuid) silently break detail panes for shared items- Never mutate TanStack Query cache directly — use
queryClient.setQueryData()orinvalidateQueries(), not direct object mutation on cached data - No
"use client"directives — this is a Vite project, not Next.js. The directive is a no-op - Every authenticated route must include
:ownerIdas the second path segment —ownerIdidentifies the Home that owns the resource. For personal data it equalsuser.id; for team data it'steam_{teamId}. This consistent prefix enables future load-balancer sharding by ownerId (all requests for one Home on the same server). Routes must validate that the caller has access to the specified ownerId (owns it or is a team member). Carve-out for home-independent routes: server-wide endpoints that don't operate on a Home — first-run setup (setup.ts), server-wide admin config (settings.ts,waitlist.ts), and unauthenticated public surfaces (public.ts) — must NOT carry:ownerId. They're protected byrequireAdmin(user.id)or their own gate, not by Home ownership - Never call
getHome()for another user's data — all cross-home interactions (where one user's action touches another user's Home) must go through the relay inhome-relay.ts:sendToHome()for push,pull*()for reads.getHome()is fine for the current request's own home. This is the sharding seam — onlyhome-relay.tschanges when homes move to different servers. See SCALABILITY.md - Use
ColumnLayout+Columnwithtoolbarprop for page layout — don't put the toolbar inside the page content. TheColumncomponent renders the toolbar in a fixed-height bar. See Page Layout Pattern above - Use existing shared components — check
packages/ui/src/components/layout/before building custom UI.TooltipButton,DeleteDialog,EmptyState, etc. already exist - Third copy → shared wrapper — the "if two+ apps need it, it goes in
packages/" rule applies to scaffolds, not just components: route guards,_auth.tsxfiles, editor shells, loading/empty/error treatments. When you're about to paste one into a third app, stop and extract a single guarded wrapper intopackages/ui(we have four near-identical EigenDoc editor routes and four sidebars rendering loaders four ways)
Backend: mutation → home.broadcast(buildEvent()) → SSE stream
Frontend: useSSE → domain handler → queryClient.invalidateQueries()
Notifications: home.notifications.persist({...}) → writes to DB + broadcasts notification:created SSE event → toast
| Type | MIME | Extension | Storage |
|---|---|---|---|
| Document | application/eigendoc |
.eigendoc |
Dir with data.db (Yjs) |
| Stickies | application/eigenstickies |
.eigenstickies |
Dir with data.db (Yjs) |
| Chat | application/eigenchat |
.eigenchat |
Dir with data.db + media/ |
| Slides | application/eigenslides |
.eigenslides |
Dir with data.db (Yjs) |
| Sheets | application/eigensheets |
.eigensheets |
Dir with data.db (Yjs) |
User = raw UUID (a1b2c3d4-...), Team = team_{teamId}. Resolution: parseOwnerId() in
packages/lib/src/types/owner.ts. Data layout: see STORAGE.md.
For iMIP (external calendar invitations), external organizers have no Eigen user ID — organizerUserId
is set to external_{email} (e.g. external_alice@example.com). Same prefix convention, same
startsWith('external_') detection idiom. See CALENDAR.md.
Tests are in apps/api/src/test/. Run with bun run test or bun test apps/api/src/test/[file].test.ts.
Integration tests (drive.test.ts, calendar.test.ts, etc.) use test helpers from setup.ts:
getTestContext()→ returns{ alice, bob, charlie }test users with session tokens and API clientsauthedRequest(token, path, options?)→ make authenticated HTTP requestdriveGet/drivePost/drivePut/driveDelete→ typed drive API helpersdriveGetPermission→ check read/write permissions
Unit tests (mount.test.ts, storage.test.ts, etc.) create isolated instances with temp directories.
See TESTING.md for full patterns.