--- title: Architecture summary: How data flows from a tap on the screen to every device: server packages, tables, client modules and the build. audience: developer section: Internals specs: [site-bundle] screens: [] order: 10 --- # Architecture OpenTodo is a local-first application with a thin sync server. Every device holds a full copy of the user's data in IndexedDB and edits it offline; the server keeps an append-only operation log, resolves it to current values, and offers the same data to other devices, scripts and integrations. ``` ┌──────────────────────── browser / PWA ─────────────────────────┐ │ React views ◄── useLiveQuery ── IndexedDB (Dexie) │ │ │ ▲ │ │ │ mutate() ──► applyOps (LWW) ───────┘ └► outbox │ │ │ │ │ sync engine: push outbox / pull since cursor ◄───────┘ │ └──────────────────────────────┬─────────────────────────────────┘ │ POST /api/v1/sync (JSON, same origin) │ GET /api/v1/sync/events (SSE wake-ups, cursor only) ┌──────────────────────────────▼─────────────────────────────────┐ │ Go server (single static binary) │ │ │ │ httpapi ── REST, auth, CalDAV (/dav), MCP (/mcp), SSE │ │ │ │ │ sync: Apply / ApplyStrict ── op log + scope_fields (SQLite) │ │ │ after commit: observer, sinks, notifier │ │ ├─► webhook queue + worker ├─► notify (in-app) │ │ └─► Notifier (wake devices) │ │ │ │ background: push scheduler · email poller · backup scheduler │ │ trash retention · audit pruning · webhook worker │ │ │ │ embedded web app (internal/web/dist) /data (SQLite) │ └────────────────────────────────────────────────────────────────┘ ``` ## Data flow ### Every write is an op All task, project, label, section, view, reminder, comment and project-preference data changes as **field-level operations** (`{entity, entityId, field, value, ts, device}`), never as direct row writes. `internal/sync` validates each op against `Schema` (`internal/sync/ops.go`), resolves its **sharing scope** (`user:` for personal data, `project:` for a shared project and everything in it), checks the author's role, stores it in `ops` / `op_scopes` and updates the winner per field in `scope_fields` by last-writer-wins (`ts`, then `device`). Replaying the same ops in any order gives the same state. The protocol is in [sync.md](sync.md). The writers are: | Writer | Path | Device id | | --- | --- | --- | | Web app on each device | `POST /api/v1/sync` → `sync.Apply` (refused ops reported per op, never block the outbox) | the device's id | | REST API, CLI | handler turns body fields into ops → `sync.ApplyStrict` (all or nothing) | `server:` (`server` in ops from older releases) | | CalDAV clients | `/dav/` PUT, PROPPATCH, MKCALENDAR, DELETE → one op per changed property | `caldav:` | | Email to task | IMAP poller → `ApplyStrict`, ids derived from account and Message-ID | `server:` | | Issue import | poller → `ApplyStrict` per page, ids derived from connection and issue | `issues:` | | Automation rules | observer queues events after commit; worker and minute scheduler → `ApplyStrict` per task, op ids derived from rule, run key and field | `rule:` | | Import | background job, batches of 1,000 ops | `import` | | Server itself | reminder delivery (`delivered_at`), unassigning people who leave a project or whose account is deleted, recurring completion via REST | `server:` | Every server-side writer takes its timestamps from one strictly increasing clock (`sync.Service.ServerTS`), and the boot id is chosen when the process starts, so two server writes never tie under last-writer-wins. Membership, accounts, tokens, settings, webhooks, audit events and backup runs are **not** ops: they are server-side account data in ordinary tables, changed only through authenticated REST routes. ### Request flow for a REST write `POST /api/v1/tasks` → auth middleware resolves the user (session cookie with origin check, or bearer token with its scope) → body fields become ops with `ts = Service.ServerTS()`, `device = "server:"` → `sync.ApplyStrict` resolves each op's scope and checks the caller's role → after commit the observer queues webhook deliveries for genuine changes (for every member who can see the scope) and notification sinks record assignments and mentions → `sync.NotifyApplied` wakes the open event streams of everyone who can see the change → the entity is re-read from `scope_fields` → JSON response. Devices receive the ops on their next sync, which the wake-up triggers at once. ### Reads REST collections, the export, MCP tools (the shared agent tool registry in `internal/agenttools`, whose write tools plan changes and apply them with `ApplyStrict` as device `agent:`) and resources, CalDAV reports and webhook payloads all read materialised entities through `sync.List` / `sync.Get` (and helpers such as `TaskComments`, `Memberships`), restricted to the scopes the caller can see. History and activity are derived on request from the op log (`ActivityOps`, `activity.Derive`); there is no separate activity table on the server. ### Live updates `GET /api/v1/sync/events` is a server-sent event stream per device (`httpapi/events.go`). It carries only the latest op cursor, never data; the client reacts by running the ordinary sync exchange. `sync.Notifier` is the in-memory per-user fan-out (at most 10 streams per account; `httpapi/events.go` also caps streams per client address and per server). See [sync.md](sync.md#live-updates). ### Background work Started by `cmd/opentodo` and stopped on shutdown: - **Reminder push scheduler** (`push.Service.Run`, unless `OPENTODO_PUSH=off`): one pass at startup and every 30 s. A single query over `scope_fields` (reminders are always personal) selects reminders whose `fire_at` has passed, not deleted, not delivered or dismissed for the current firing, whose task is open; each is encrypted and posted to every subscription of the owner (404/410 removes the subscription), then `delivered_at` is written through `sync.Apply` as device `server`. Delivery state lives in the op log, so a restart neither loses nor repeats a push (a crash between send and write can repeat one). - **Webhook worker** (`webhook.Run`): sends queued deliveries with retries; finishes the pass in progress on shutdown. - **Notification channel worker** (`channels.Run`): sends queued messages to ntfy, Pushover, Gotify and webhook channels with the same retries; never on a demo instance. - **Email poller** (`email.Run`): polls the configured IMAP mailbox, only when `OPENTODO_EMAIL_*` is set. - **Backup scheduler** (`backup.Start`): runs scheduled backups; a missed run executes once at startup. - **Trash retention** (`sync.RunRetention`): purges items older than `OPENTODO_TRASH_RETENTION_DAYS` at startup and daily. - **Audit pruning**: at startup and hourly. Old read notifications are swept at startup. ## Server packages (`cmd/opentodo`, `internal/`) | Package | Responsibility | | --- | --- | | `config` | `OPENTODO_*` environment variables with defaults and validation; nothing is mandatory | | `codesize` | Test only: `go test ./...` fails when a function under `internal/` is longer than 150 lines or a file longer than 1,100 lines; exceptions need a reason, and the list can only shrink | | `store` | Opens SQLite (pure-Go `modernc.org/sqlite`, WAL): one writer connection whose write transactions begin `IMMEDIATE`, and a pool of query-only reader connections (`OpenReader`) for pure reads such as `sync` List, Get and Pull; applies embedded migrations (`migrations/NNNN_*.sql`) by `PRAGMA user_version`; runs `PRAGMA optimize` at open and daily; `Lock` holds `/opentodo.lock` for the process lifetime | | `stt` | Speech to text: the owner's OpenAI-compatible transcription service (`OPENTODO_STT_*` overrides, key in `/secrets/stt.json`), requests through `outbound`, audio and text in memory only. See [speech-to-text.md](self-hosting/speech-to-text.md) | | `textutil` | Small string helpers shared by server packages: `Truncate` cuts a string to a byte limit on a character boundary, for names and messages stored under a length limit | | `tenant` | Builds one server around one data directory: the database and its lock, the services, the notifier, the HTTP handler and the background jobs. A self-hosted server opens exactly one tenant. Its sub-package `services` is the composition root: `Build` constructs, seeds and wires every service into a `Set`, which `httpapi.New` receives; registering routes builds nothing | | `host` | Optional multi-tenant host, built only with `OPENTODO_HOST_URL`: a registry of tenants beside their data directories, host-name routing, lazy open and idle close, one scheduler for every tenant's background work, per-tenant limits and the operator interface a control plane drives | | `settings` | Owner-level key/value settings (`settings` table, JSON values), the owner on/off switches (`Switch`: cached, with a default and an optional environment override) and 0600 secret files under `/secrets/` | | `sync` | Op schema and validation, `Apply` (idempotent, LWW, per-op authorisation and refusal reasons) and `ApplyStrict` (REST), `Pull` (cursor, member backfill), materialised reads, sharing scopes (`scope.go`) and membership (`members.go`), trash, history and purge, the post-commit observer and sinks, `Notifier` (per-user live wake-ups) | | `auth` | Users and roles, argon2id passwords, sessions, API tokens and their scopes, CalDAV app passwords, invitations, password resets, user administration, TOTP and recovery codes, step-up, passkeys storage, provider identities (subject-first matching, verified-email linking, provisioning, the SSO-only rule) | | `webauthn` | Passkey verification: clientDataJSON, authenticator data, COSE keys (ES256, Ed25519, RS256), signature checks; uses `fxamacker/cbor` for CBOR (the other third-party Go modules are SQLite and `golang.org/x/crypto` for argon2). `testauth` is a synthetic authenticator for tests | | `oidc` | Single sign-on with one OpenID Connect provider, standard library only: discovery, PKCE, code exchange, JWKS cache, identity-token verification, the signed flow-state cookie, owner configuration and `OPENTODO_OIDC_*` overrides. `oidctest` is an in-process provider for tests. See [self-hosting/sso.md](self-hosting/sso.md) | | `audit` | The closed catalogue of security events with typed constructors and an allow-list for details, recording (in the same transaction as credential changes), queries, NDJSON export and retention pruning | | `outbound` | The policy for every request to an address a user or the owner typed: http(s) only, no private network targets unless the owner allows them, the connection pinned to the address that was checked. Used by webhooks (and through their client by push and the assistant), by calendar subscriptions and by issue connections | | `demo` | The public demo instance (`OPENTODO_DEMO=true`): a hidden owner, a private short-lived sandbox per visitor seeded through the op log, the nightly reset; the HTTP side and its deny list are in `httpapi/demo.go` | | `httpapi` | Router (stdlib `net/http` with method patterns), middleware (auth, origin check, security headers, rate limits, logging), JSON error envelope, handlers for every `/api/v1` route, the CalDAV app passwords and switch (`caldav.go`), the MCP transport (`mcp.go`) and the event stream. Its sub-package `httpx` holds what route packages share with it (the route policy and `Router`, the JSON envelope and body helpers, the caller of a request); route packages register through that `Router` and never build a mux: `speech` (speech to text), `maproutes` (the map proxy) and `davroutes` (the CalDAV endpoint `/dav/`) | | `web` | Embeds the Vite build (`internal/web/dist`) and serves it with SPA fallback and cache headers | | `notify` | Post-commit events for people: the `Sink` interface the sync service calls after a batch commits, and the in-app sink that stores `notifications` for assignments and mentions | | `channels` | A person's delivery channels (ntfy, Pushover, Gotify, a signed webhook): a notify sink and a consumer of the reminder scheduler that queue one message per channel and firing, one worker through `outbound` with the webhooks' retries, secrets in `/secrets/notification-channels.json`. See [notification-channels.md](notification-channels.md) | | `activity` | Pure derivation of attributed activity events from ops (fold by actor and entity within 5 s, classify by touched fields, page newest first); mirrored by `web/src/lib/activity.ts` and pinned by a shared fixture | | `webhook` | Maps observed changes to events, durable queue in SQLite, one worker with retries, HMAC signing, SSRF-safe HTTP client. See [webhooks.md](webhooks.md) | | `deliveryq` | The machinery of the two durable outbound queues, webhook deliveries and notification channel messages: the single worker loop, the parallel pass over due rows, pruning the log, and the next-due times a host indexes. Each queue keeps its payloads, its send and the recording of an attempt (the retry schedule is `webhook.Backoff`) | | `poll` | The shape shared by the subscription pollers (calendar feeds, issue connections): `Every` runs a pass on a ticker, `Each` handles the items due now with bounded concurrency | | `push` | Web push for reminders with the standard library: VAPID key pair in `/push_vapid.pem`, ES256 JWTs, RFC 8291 `aes128gcm`, per-device subscriptions and the scheduler. See [reminders.md](reminders.md) | | `recurrence` | Parser and next-occurrence calculator for recurrence rules; mirrored by `web/src/lib/recurrence.ts` against shared vectors. See [recurrence.md](recurrence.md) | | `quickadd` | Go port of the quick-add parser (`web/src/lib/nlp`): dates and times, `#project @label p1 +30m`, recurrence and reminder phrases in six languages, kept identical by shared vectors; used by the CLI and the importer | | `zoned` | Resolves time-zone-fixed due values to instants (gaps and overlaps included); mirrored by `web/src/lib/dates.ts` against shared vectors | | `inbox` | Inbox membership: open, without a project and not scheduled (no do-on date, start date or Someday), with no placed parent; mirrored by `web/src/lib/inbox.ts` against shared vectors and used by the agent tools, the MCP views and the CLI | | `filter` | Go port of the filter query parser, used to validate saved views; evaluation stays on the client. See [filters.md](filters.md) | | `origin` | A task's origin (where it came from): the one validation of the `task.origin` value (the sync schema's check, the CLI, the agent tools, the importers, email to task) and the table that recognises GitHub, GitLab, Jira and Linear issue links, kept identical to `web/src/lib/taskOrigin.ts` by shared vectors. Never requests a URL. See [capture.md](capture.md#where-a-task-came-from) | | `timetrack` | Tracked time (OpenSpec time-tracking): the one-running-entry rule every device and the time entry routes apply, an entry's minutes and the day it belongs to, typed durations (`45m`, `1h30`) and the time entries CSV, kept identical to `web/src/lib/time.ts` by shared fixtures. See [time-tracking.md](time-tracking.md) | | `importer` | Reads the own export, CSV and other apps' JSON exports into rows, plans projects, labels, parents and duplicates, and writes ops in background jobs with undo. `sanitize` is a command that turns a real export into a test fixture; it is never linked into the server. See [import.md](import.md) | | `blobs` | Attachment bytes as immutable files under `/attachments/`, written once through a temporary file with the size and SHA-256 checked, streamed and never read whole. See [attachments.md](attachments.md) | | `calfeed` | Calendar feeds: other people's calendars read into read-only events (fetched through `outbound`), and the user's tasks written as an iCalendar feed. See [calendar-feeds.md](calendar-feeds.md) | | `ruleval` | Automation rule values (trigger, condition, actions) with their checks and the condition matcher; a leaf package `sync` uses for the `rule` entity's kinds, pinned to the web matcher by `web/src/lib/rules/match.vectors.json`. See [automations.md](automations.md) | | `rules` | Automation rules engine: the planner (shared fixture with the web planner), the post-commit observer, the durable queue, the worker with limits and loop depth, the minute scheduler for time triggers and the run log; the owner switch with `OPENTODO_AUTOMATIONS`. See [automations.md](automations.md) | | `calsync` | Calendar sync: a user's own CalDAV or Google calendar receives their dated tasks as events and sends changes back. Connections, secrets in `/secrets/calendar-sync.json`, a WebDAV client on `outbound` (discovery, `sync-collection`, conditional writes), Google OAuth with PKCE, and the cycle on the jobs leader that pulls before it pushes and writes ops with device `calsync:`; shown calendars are stored as managed `calfeed` subscriptions. `calsynctest` is an in-process CalDAV server for tests. See [calendar-sync.md](calendar-sync.md) | | `issues` | Issue import: per-user GitHub and GitLab connections, tokens in `/secrets/issues.json`, two REST clients on `outbound` without redirects, the mapping of issues to task ops (deterministic ids, a field changes only when the issue changed it) and the poller on the jobs leader. Never writes to the provider. See [issue-import.md](issue-import.md) | | `backup` | Snapshots (`VACUUM INTO`), tar/gzip archives, `OTBK1` encryption, local / S3 (SigV4) / WebDAV destinations, scheduler, retention, `Restore`. See [backups.md](backups.md) | | `caldav` | Protocol pieces of the CalDAV endpoint: iCalendar tokenizer and serialiser for VTODO, the VTODO ↔ task mapping (unmapped lines kept in `ical_extra`), WebDAV XML parsing and multistatus rendering; HTTP handling lives in `httpapi`. See [caldav.md](caldav.md) | | `mcp` | Model Context Protocol server: a JSON-RPC 2.0 subset that serves the tools of `agenttools` (read tools always; write tools behind the owner's switch, the user's switch and an `agent` token) and read-only `opentodo://` resources, plus the owner switches. See [mcp.md](mcp.md) | | `relay` | Device relay for encrypted accounts (mcp-device-relay): opted-in devices bound to their session, an in-memory hub that sends each MCP call's decrypt and encrypt requests, sealed per stream (ECDH P-256, HKDF, AES-GCM), to one of the account's online devices and waits for the answer within the relay limits. Never stores, caches or logs a relayed value | | `agenttools` | The one registry of tools an AI agent can call (definitions, argument validation, planning into change sets, untrusted-text marking, write limits, the write permission gate, `agent.write` audit), shared by MCP and the in-app assistant; writes only through `ApplyStrict`. Also the per-user "Allow AI assistants to make changes" switch | | `assistant` | The in-app assistant: the owner's configuration and provider keys, per-user consent and token usage, the model providers (Anthropic through the official Go SDK, one standard-library Chat Completions adapter for OpenAI, Gemini and compatible endpoints) and the agent loop that runs read tools and plans write tools without applying them; the browser applies the confirmed part. See [assistant.md](assistant.md) | | `email` | Email to task: minimal IMAP client (TLS or STARTTLS), MIME parsing to title and description, sub-address routing, per-account inboxes, limits and the poller. See [email.md](email.md) | | `location` | A task's location and a saved location's coordinates: the one validation every write path uses (sync, REST, the importers). See [locations-and-places.md](locations-and-places.md) | | `maps` | The map proxy: the owner's switch with `OPENTODO_MAPS*` overrides, the provider's style rewritten so every URL points back to the server, tiles, fonts and sprites cached under `/cache/maps` (LRU, capped, never backed up), and Photon search. No viewer data reaches the provider. See [self-hosting/maps.md](self-hosting/maps.md) | | `mail` | Optional outgoing email: the owner's SMTP configuration with `OPENTODO_SMTP_*` overrides, a standard-library SMTP transport with an explicit TLS policy, plain-text templates in six languages, sending limits and audit events; every message goes through `Service.Send`. `smtptest` is an in-process SMTP server for tests | | `digest` | The opt-in weekly email digest: per-user settings, the scheduler on the jobs leader, and the content built from the user's materialised rows with the same definitions as the Statistics screen | | `e2ee` | Server side of end-to-end encryption: stores the wrapped key record with optimistic versioning, recognises ciphertext so server-side features degrade explicitly, purges superseded content ops. The server runs no content cryptography; its sub-package `crypt` is the same format for the `ot` client only (`ot encryption`, `ot mcp --decrypt`), on the standard library, pinned by the shared vectors and never linked into the server. See [e2ee.md](e2ee.md) | | `ids` | UUID v4, name-based ids (SHA-256, version 8 UUIDs, for recurring occurrences and tasks created from email) and random secrets | | `imgfit` | Scales the images the server stores, on the standard library alone: the centre square of a sign-in provider's picture to 256 px, the owner's logo to every icon size and a padded maskable icon (`oidc-profile-pictures`, `instance-branding`) | | `iconset` | The icon catalogue's names, categories and keywords, generated from `web/src/ui/icons/catalogue.json` and checked against it by a test; `Valid` checks an icon name in REST and MCP writes, `Search` orders matches as the app's icon picker does | | `cli` | The `ot` command-line client (`cmd/ot`): profiles, REST calls, table and `--json` output. See [cli.md](cli.md) | | `vault` | The Markdown vault behind `ot vault`, for the client only (a test keeps it out of the server): the Obsidian Tasks line grammar, the document parser and printer, the state file, the three-way merge per field and the sync loop over `POST /api/v1/sync` as device `vault:`. See [vault.md](vault.md) | ### Tables | Area | Tables | | --- | --- | | Op log and current values | `ops` (append-only, `user_id` = author, `seq` autoincrement), `op_scopes` (the sharing scope of each op), `scope_fields` (winner per field within a scope), `purges` (markers for permanently deleted entities) | | Sharing | `project_scopes`, `project_members` (effective roles, outside the op log; `via` names the groups a role comes from), `project_grants` (direct and group grants; `project_members` is recomputed from them), `member_backfills` (history replay per member device) | | Workspaces | `workspaces` (one `default` per account), `workspace_members`, `groups`, `group_members`, `workspace_invites` (hashed link secrets) | | Accounts and credentials | `users`, `user_status`, `sessions`, `session_auth`, `session_oidc`, `api_tokens`, `api_token_scopes`, `api_token_agent`, `user_agent_access`, `app_passwords`, `passkeys`, `identities`, `mfa_totp`, `mfa_recovery_codes`, `trusted_devices`, `invitations`, `password_resets` | | Features | `notifications`, `push_subscriptions`, `notification_channels`, `notification_deliveries`, `webhooks`, `webhook_deliveries`, `import_jobs`, `import_items`, `caldav_resources`, `email_inboxes`, `email_receipts`, `encryption_keys` | | Operations | `settings` (owner key/value settings, also the log `generation`), `audit_events`, `backup_runs` | Files in the data directory: `opentodo.db` (plus WAL files), `opentodo.lock`, `push_vapid.pem`, `secrets/` (backup and single sign-on secrets, 0600, never archived) and `backups/` (default local backup folder). ## Client (`web/`) | Module | Responsibility | | --- | --- | | `lib/db.ts` | Dexie database: `projects`, `tasks`, `labels`, `views`, `reminders`, `sections`, `comments`, `prefs` (synced `project_prefs`), `outbox`, `meta` (device settings), plus caches (`memberships`, `notifications`) and the local `activity` log | | `lib/types.ts` | Entity types; each extends its op fields from `generated/opSchema.ts`, which `internal/sync` generates from `Schema` with the row defaults and content fields | | `lib/hlc.ts` | Hybrid logical clock and the shared `wins()` rule | | `lib/ops.ts` | `applyOps` (per-field LWW into IndexedDB, merge-and-republish of note documents), `mutate` (local apply + outbox + notify), entity helpers and defaults | | `lib/sync.ts` | Background push/pull loop, triggers, live-update channel (`EventSource` with backoff), status store (`useSyncStatus`) | | `lib/api.ts` | Fetch wrappers and `ApiError` | | `lib/e2ee.ts`, `lib/crypto.ts`, `lib/e2eeSync.ts` | End-to-end encryption: key record handling, AES-256-GCM field encryption, encrypt at upload and decrypt before apply | | `lib/nlp/` | Natural-language quick add (tokenizer, dates, attributes, languages) | | `lib/filter/`, `lib/filters.ts` | Filter query parser and evaluator, duration predicates | | `lib/recurrence.ts`, `lib/recurring.ts` | Recurrence grammar and the completion, skip and detach flows | | `lib/dates.ts` | Floating and zoned due values | | `lib/ordering.ts`, `lib/dnd.ts` | Fractional positions and pointer drag and drop (no library) | | `lib/reminders.ts`, `lib/reminderTime.ts`, `lib/reminderWatcher.ts`, `lib/push.ts` | Reminder writes and time arithmetic, the in-app watcher, the device's push subscription | | `lib/comments.ts`, `lib/activity.ts`, `lib/feed.ts`, `lib/markdown.ts` | Comments and mentions; the shared activity rule, local recording and merging of older server pages; the cross-project feed and unread counts; the in-house Markdown subset (rendered by `app/Markdown.tsx` as React elements, so raw HTML never reaches the DOM) | | `lib/docfields.ts`, `lib/richtext.ts`, `lib/richtextEditor.ts`, `app/NotesEditor.tsx` | Rich-text notes ([sync.md](sync.md#mergeable-documents)): the document field list and lazy loader; the Yjs document code (envelope, merge, growth check, Markdown projection); the ProseMirror schema and binding; the editor component. The last three are lazy chunks | | `lib/share.ts` | Parsing of share-target and bookmarklet payloads | | `public/push-sw.js` | Imported by the generated service worker: `push`, `notificationclick` (snooze and dismiss written as ops into the outbox), `notificationclose` | | `app/` | Router, auth screens, shell, views (lists, board, calendar, filters, dashboards, feed, trash), detail panel, settings panels, Vim layer (`app/vim/`, lazy) | The UI never talks to the server for task data. Views subscribe to IndexedDB with `useLiveQuery`; local edits and remote ops surface through the same path. Screens in `web/src/app` never open IndexedDB themselves: they read through `web/src/lib` (`localData.ts` and `liveTasks.ts`, enforced by ESLint), where widely read tables (tasks, projects, labels, memberships, views, preferences, field definitions) each have one shared live snapshot instead of every component scanning the table on every change, and where one visibility rule decides which projects a screen sees (live ones, archived included; browsing lists narrow them with `visibleProjects`; only the Trash, history and statistics read deleted ones). Shared UI in `web/src/ui`, overlays included, never imports a screen. Account-level screens (users, audit log, backups, webhooks, tokens) call the REST API directly and need a connection. ## Build and packaging `web/` builds into `internal/web/dist`, which `go:embed` compiles into the binary (`internal/web/dist/.gitkeep` keeps the embed compiling on a fresh clone). The Dockerfile runs both builds on the build platform, cross-compiles a static binary (`CGO_ENABLED=0`, `-trimpath -s -w`) and copies it into `gcr.io/distroless/static-debian12:nonroot`. CI publishes `linux/amd64` and `linux/arm64` images to GHCR and attaches the server and `ot` binaries, the browser extension zip, the agent skill and the **site bundle** (`opentodo-site-bundle-.tar.gz`: the docs website, screenshots, colour tokens, icons, the brand mark, changelog and a feature manifest, which the website repository builds from) to releases; every CI run uploads the same bundle as an artefact. The server binary is about 34 MB for linux/amd64, as released (about 5 MB of it is the official Anthropic SDK for the in-app assistant, about 1.6 MB the five interface language catalogs). Nothing in the server may use `text/template`, `html/template` or `reflect.Value.MethodByName`: any of them stops the linker from pruning unused methods and adds about 13 MB. ## Specs Behaviour is specified in `openspec/specs//spec.md` (live) and proposed in `openspec/changes/`; shipped changes are archived under `openspec/changes/archive/`. See [CONTRIBUTING.md](../CONTRIBUTING.md).