# Setup For the in-app desktop and browser guide, see [first-run setup](specs/onboarding/first-run.md). Desktop app-account sign-in is separate from AI-engine sign-in. The authoritative quick start is the [README](../README.md#quick-start). For the desktop app from source, use Node.js 22+, `uv`, and a ready engine; Docker is needed only for optional agent computers. For the development server, use Node.js 22+, `uv`, and the configured database (PostgreSQL is the default; SQLite is available for local development). The older Boat and Telegram task flow is optional and separate from the agent workspace. For a development checkout, see the setup notes in [backend](../backend/README.md) and [frontend](../frontend/README.md), then start with `scripts/dev.sh`. For the desktop app, see [Desktop app](#desktop-app-from-a-source-checkout-experimental). Neither path requires a Boat API key for ordinary agent chat or local routines. Component-specific details: [backend/README.md](../backend/README.md), [frontend/README.md](../frontend/README.md). ## Signing in and pairing devices Running on a server of your own: [SELF-HOSTING.md](SELF-HOSTING.md). Outside the desktop app every request needs a sign-in. Full design and tradeoffs: [AUTH.md](AUTH.md). 1. Run `alembic upgrade head` (`scripts/dev.sh` does). 2. **First start:** open the web UI on the machine that runs the backend; it asks you to set the owner passphrase (at least 10 characters). From any other machine the page tells you to run, on the server: ```sh cd backend && uv run python -m app.auth.cli set-passphrase ``` The same command replaces a forgotten passphrase (every browser is then signed out). 3. **Pair a phone or another browser:** **Settings → Devices → Create pairing code**, choose *Chat only* or *Full access*, then scan the QR code or open `/pair` on the other device and type the code (5 minutes, single use). Revoke a device from the same list. From the shell: `uv run python -m app.auth.cli pairing-code [--scope chat]`, `devices`, `revoke ID`. 4. **Scripts:** pair them (`POST /api/v1/auth/pair` with `{"code": "…"}` returns a token once) and send `Authorization: Bearer `. 5. **An older browser** that kept its guest or Telegram id in `localStorage` hands it to the server after the first sign-in and forgets it; your tasks and schedules stay yours. Settings: `TRUSTED_HOSTS` (default `localhost,127.0.0.1,[::1]`; add your public host name when self-hosting), `LOOPBACK_TRUST` (`off` by default; `programs` lets scripts on this machine skip sign-in, `all` also local browsers, development only), `FRONTEND_ORIGINS` (also the origins allowed to send cookie requests). Self-hosting: put HTTPS in front, serve the web UI and the API from the same site, and see the checklist in [AUTH.md](AUTH.md#self-hosting-checklist). ## Search, attachments, files and usage Nothing to set up; see [EVERYDAY.md](EVERYDAY.md). Attached files are kept in `~/.open-dots/attachments//` (override with `ATTACHMENTS_DIR`; the desktop app uses its data folder), folders `0700` and files `0600`, and are deleted with their conversation. The Files and Usage panels need an admin session; a device paired with the `chat` scope can search and attach but cannot browse workspace files or see usage. No migration is needed. ## Database **PostgreSQL is the default** and is what the Docker setup above starts. Nothing changes for an existing installation. **SQLite (experimental, opt-in):** one local file, no Docker. Set `DATABASE_BACKEND=sqlite` in `backend/.env` (optionally `SQLITE_PATH`; the default is `~/.open-dots/open-dots.db`), then run `uv run alembic upgrade head` (or `scripts/dev.sh`, which skips Docker for SQLite). A new SQLite file is created from a frozen baseline of the current schema and stamped at the current revision; an existing file is upgraded like any other. A SQLite file that is not one this app created (tables but no version table, an empty version table, missing app tables, or not a SQLite file at all) is refused and left byte-for-byte unchanged. What to know before using SQLite: - The database holds encrypted credentials. The file is created readable only by you on Linux and macOS; on Windows it lives in your profile folder. Keep it out of synced or shared folders. Back it up while the app is stopped, or with SQLite's backup command; do not copy the file while the app is running. - It runs in WAL mode with foreign keys on and a 5 second busy timeout. Writers take turns. The places that relied on PostgreSQL row locks or on a transaction staying open were reworked so they behave the same on both databases: the permission-approval wait, conflicting answers to one approval, reply admission, schedule claiming, and the code that talks to a sandbox or the Claude CLI (it no longer holds a database snapshot while it waits). The test suite checks these with several simultaneous connections on both databases. - Conversation ids are never reused: the `thread`, `turn` and `event` tables are `AUTOINCREMENT` on SQLite, so deleting a conversation never gives its id to the next one, and a saved `/chat?thread=N` link or a decision-log entry keeps naming the conversation it was made for (or none, once it is deleted). PostgreSQL never reused them. Databases made before this change are converted by `alembic upgrade head` (revision `8d5f3a2b6c74`), which rebuilds those four tables in one transaction, keeping every row, id, index and foreign key; the desktop app copies the file aside first, as before any update, and for a source install stop the app and back the file up yourself. An id deleted before the upgrade that was higher than every remaining one can still be handed out once afterwards, except a conversation id the decision log still names: new conversations start above those. The upgrade refuses, changing nothing, if a table it does not know has a foreign key to these tables. - SQLite and PostgreSQL are both exercised by the backend test suite and CI. Keep PostgreSQL for multi-backend scheduler operation and the legacy Boat sandbox flow unless you have verified the exact SQLite deployment shape you need. - To bring existing PostgreSQL data along, see [Moving existing data from PostgreSQL to SQLite](#moving-existing-data-from-postgresql-to-sqlite). - Tested on Windows only. The code avoids OS-specific behaviour (paths use forward slashes in the database URL, POSIX file modes are applied only where they exist) but Linux and macOS runs are still to do. ### Moving existing data from PostgreSQL to SQLite Copying and switching are two separate steps. The tool copies; you switch yourself afterwards. 1. **Stop the backend, the Telegram bot and the scheduler** (anything that writes to the database). The tool refuses if it sees active tasks, pending approvals or other connections on the database; `--allow-active` overrides that, but a copy taken while something is still writing can miss what it writes next. 2. From `backend/`, with `backend/.env` still pointing at PostgreSQL and the same `TOKEN_ENCRYPTION_KEYS` you used so far: ``` uv run python -m app.db.copy_to_sqlite --dry-run # checks and counts, creates nothing uv run python -m app.db.copy_to_sqlite # copies to SQLITE_PATH (default ~/.open-dots/open-dots.db) ``` `--target PATH` writes elsewhere. The target must not exist: the tool never overwrites a file (move or delete an old one yourself). 3. The tool reads PostgreSQL in one read-only snapshot, builds the SQLite file under a temporary name beside the target, copies every table keeping ids, then checks it: row counts, every row compared, every stored credential decrypted with your key, SQLite `integrity_check` and `foreign_key_check`. Only if everything passes is the file published. If anything fails, nothing is left behind. It prints table names and counts only, never row values. 4. To use SQLite, set `DATABASE_BACKEND=sqlite` in `backend/.env` (plus `SQLITE_PATH` if you used `--target`) and start the app. The tool does not change any setting. **If something goes wrong** - *"... already exists" (the target).* The app creates its SQLite file as soon as it starts with `DATABASE_BACKEND=sqlite`, and the tool's default target is that same file. Either run the copy before the app has ever started on SQLite, or use `--target` with a new name. The message says what is in the existing file (empty, an Open Duet database with N tasks, or something else). The tool never overwrites it; move or rename it yourself. - *A `-wal` or `-shm` file is named.* SQLite keeps these next to a database while it is open. Move or rename them together with the `.db` file (same base name), and stop the backend first: they normally disappear when it stops cleanly. The tool refuses if only a side file remains, because it could be attached to a new database by mistake. - *The page is empty after switching.* The app opened a different database file than the one you copied to. If you used `--target`, set `SQLITE_PATH` to it in `backend/.env`. The backend logs `Database in use: ...` at startup, so you can see which file it opened. Also use the same browser and address as before: the page remembers your user id in that browser. - *Refused for active tasks or open connections.* Stop the backend, the bot and any database tool that is connected, then run it again. - Inspecting an existing file to describe it never changes the database or its write-ahead log (SQLite's `-shm` index file may be touched, as by any reader). **Rolling back:** set `DATABASE_BACKEND` back to `postgres` (or remove it). PostgreSQL still holds its data as it was when you copied; anything written in SQLite after you switched is not copied back, so rolling back loses it. Keep the PostgreSQL container and its volume until you are sure. Tested on Windows with PostgreSQL 17 in Docker (`postgres:17-alpine`); the code avoids OS-specific behaviour (the no-overwrite publish uses a hard link, which fails atomically when the target exists, with a rename fallback for filesystems that have no hard links), but Linux and macOS have not been run yet. ## Local Claude (experimental) Runs Claude through the `claude` CLI already installed and signed in on the machine that runs the backend, instead of a Boat sandbox. By default it is **chat-only**: tools, MCP servers and skills are all disabled, so Claude can answer but cannot read files or run commands. With `LOCAL_TOOLS_ENABLED=true` it can also use tools, each one waiting for your approval (see [Tools and approvals](#tools-and-approvals-local-claude)). The answer streams into the page as Claude writes it; the page keeps only a list of past answers (for conversations that continue and survive a restart, see [Durable threads](#durable-threads-experimental)). 1. Install Claude Code and sign in (`claude auth login`). 2. In `backend/.env` set `LOCAL_EXECUTION_ENABLED=true` and restart the backend. Optionally set `LOCAL_CLAUDE_CLI_PATH` to the full path of the real `claude` executable (on Windows the npm `claude.cmd` shim is resolved to the real `claude.exe` automatically) and `LOCAL_WORKSPACE_ROOT` (default `~/.open-dots/local`). 3. Open the web UI from this machine. A "Local Claude" card appears when the CLI is ready, or explains why it is not. Who can use it: requests that come from this machine, with a `localhost`, `127.0.0.1` or `[::1]` Host header, no proxy or tunnel headers, and a browser Origin listed in `FRONTEND_ORIGINS` (for example, opening the UI at `http://127.0.0.1:3000` requires adding that origin), and requests from a signed-in owner or a paired device ([Signing in](#signing-in-and-pairing-devices)). Schedules, Telegram and the older task endpoints cannot use it. Every request must also pass sign-in first, so a browser on this machine signs in too unless `LOOPBACK_TRUST` says otherwise. A paired device can run Claude on this machine: pair phones with *Chat only* and revoke a lost one at once. Settings: `LOCAL_EXECUTION_ENABLED` (default `false`), `LOCAL_TOOLS_ENABLED` (default `false`), `LOCAL_APPROVAL_TIMEOUT_SECONDS` (default 300), `LOCAL_ALLOWED_HOSTS`, `LOCAL_CLAUDE_CLI_PATH`, `LOCAL_WORKSPACE_ROOT`, `LOCAL_TASK_TIMEOUT_SECONDS` (default 300), `LOCAL_MAX_OUTPUT_BYTES` (default 1000000; the limit on the answer text, and the raw CLI stream may be up to 8 times larger). Streaming: the page opens a server-sent-events stream (`GET /api/v1/local/tasks/{id}/events`) for each run and shows the text as it arrives; the final result and any error always come from the saved task. Closing or refreshing the tab does not stop a run (it ends or times out on its own), and reopening the page shows the answer so far. Streams are held in memory, so live streaming needs a single backend process; with several workers, or after a restart, the page falls back to polling the saved task. Nothing is replayed after a disconnect. ### Tools and approvals (local Claude) Set `LOCAL_TOOLS_ENABLED=true` in `backend/.env` and restart the backend. Claude can then use six tools in its workspace folder (`LOCAL_WORKSPACE_ROOT`, default `~/.open-dots/local/workspace`): `Read`, `Write`, `Edit`, `Glob`, `Grep` and `Bash`. There is no web access, no sub-agents and no MCP servers. How it works: the page shows each tool call as a step in a timeline. When the CLI would ask a person before running something, the run stops and the page shows a card with the tool and its exact input (the full command, the file path and the new contents) and **Allow** and **Deny** buttons. Nothing runs until you press Allow, and what runs is exactly what the card showed. Deny, an unanswered question (`LOCAL_APPROVAL_TIMEOUT_SECONDS`, default 300) or a run that ends all count as a refusal; Claude is told and carries on. There is no "always allow". A request too large to be shown in full (over 50000 characters) is refused rather than approved unseen. Time spent waiting for you does not count towards `LOCAL_TASK_TIMEOUT_SECONDS`. What you must know before turning it on: - Once you press Allow, a command runs **as you, on this computer, without a sandbox**. Read the command before approving. This is the same trust you give the Claude CLI in a terminal. - The CLI, not this app, decides which calls ask. In its default mode it asks for writes, for anything outside the workspace folder, and for commands it does not recognise as read-only. A few read-only commands (for example `echo` and `ls`) and reads inside the workspace run without asking, and permission rules in your own Claude settings are not turned off by this app, so a rule there that allows something will let it run unasked. Tested with Claude Code 2.1.232: a write inside the workspace, a read of `C:\Windows\win.ini` and `python -c ...` each asked; denying each stopped it. - Approvals and the timeline live in memory, like the answer stream (single backend process). The timeline is shown while you keep the page open and is not saved; the saved task keeps only the answer. If the backend restarts during a run, the run ends as interrupted. - Each local task started from the page is a new conversation. For conversations that continue, see [Durable threads](#durable-threads-experimental). - Anyone who can already reach the local routes (see "Who can use it") can approve, because there is no password. Keep it off shared machines. - The Claude CLI has its own memory feature that can save notes under your `~/.claude/projects/` folder, outside the workspace, without an approval card. Runs started by this app turn it off (`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` for that process only), so the app's conversations cannot write there; your own use of the CLI is unchanged. Checked with Claude Code 2.1.232: with the variable set, a "remember this" request wrote no notes. Tested on Windows with Claude Code 2.1.232. The code is written for Windows, macOS and Linux (OS-specific parts are guarded in `local_claude_runner.py`: the npm `claude.cmd` shim, `taskkill` versus `killpg`, console-window flags), but macOS and Linux have not been run yet. ## Approval modes, saved rules and the decision log These apply to local Claude turns with tools on (`LOCAL_TOOLS_ENABLED`). Everything here is local access only. **Modes.** `ask` (the default) leaves every question to the Claude CLI's default mode. `accept_edits` lets file edits in the workspace through without a card; commands and anything else the CLI would ask about still ask. `plan` makes the turn read-only for your files; the Claude CLI still saves the plan it writes as a file in its own plans folder (`~/.claude/plans` by default), outside the workspace and without a card, the way it keeps its own copy of each session. The mode applies to the next turn, including one that resumes an existing session. There is no mode that skips permissions. Chat-only runs (tools off) stay locked down whatever the mode. **Always allow.** Answer an approval with `{"decision": "allow", "remember": true}` and that exact Bash command is saved for that conversation's workspace folder. Manage rules with `GET /api/v1/approval-rules`, `POST /api/v1/approval-rules` (`{"match": "exact" | "prefix", "command": "...", "workspace": "/abs/folder" (optional)}`) and `DELETE /api/v1/approval-rules/{id}`. - `exact` matches the whole command; `prefix` matches the leading words (`git status` allows `git status -s`) and never a command containing `;`, `&`, `|`, `<`, `>`, a backtick, `$(` or a newline. - A rule with a workspace applies only there, matched on the folder exactly as stored (no symlink or `..` folding). A rule without one applies in every folder. - Only the Bash tool has rules. Commands containing a credential, control characters, or anything on the safety floor cannot be saved. At most 200 rules. - A matching rule answers the question immediately. The approval is still recorded and shown in the conversation, and its `approval.resolved` event carries `"by": "saved_rule"` and the `rule_id`, so the Chat page says a rule allowed it rather than you. - The answer to `remember` says what happened: `rule_id` is the saved rule, or it is null and `remember_error` says why nothing was saved (a credential, the safety floor, a conversation without a folder...); the call was then allowed this once only. - `GET /api/v1/approval-rules?workspace=/abs/folder` lists the rules that apply in that folder: the ones tied to it and the ones with no folder. - **Undo.** Deleting a rule (`DELETE /api/v1/approval-rules/{id}`, optionally `?thread_id=` for the conversation it was undone from) is how you take an "always allow" back. It is logged as an `undone` decision, and the next matching command asks again. **Safety floor.** These are never allowed by a rule; a card appears and only you can allow them: recursive delete of `/`, your home folder or a system directory; recursive `chmod`/`chown` of those; formatting or overwriting a disk device (`mkfs`, `wipefs`, `diskutil eraseDisk`/`eraseVolume`/`reformat`/`zeroDisk`, `dd of=/dev/...`, `> /dev/sda`, including `/dev/disk/by-id/...` and `/dev/md/...`); `git push` with `--force`, `-f`, `--force-with-lease` or `+ref`; fork bombs (`:(){ :|:& };:` and the `function f { f|f& }; f` spelling). The words given to `eval` are read as a command too. A command the reader cannot follow to the end (an unbalanced quote, `$(...)`, backticks, `eval` of a variable, or a program named by a variable such as `$CMD` or `bash -c "$CMD"`) is held the same way. The floor lives only in front of automatic allows: it does not change what the CLI decides for itself, so `accept_edits` still lets edits through and a command the CLI never asks about is not seen by the app. **Decision log.** One line per event (`shown`, `allowed`, `denied`, `auto_allowed`, `expired`, `cancelled`, and `undone` when you remove a saved rule) with the time, thread, turn, tool, a one-line summary, the conversation's approval mode (`mode`), who or what decided (`source`: `user`, `saved_rule`, `timeout`, `provider`), and which rule (`rule`: the saved command, or `safety_floor:` when a rule matched but the call was asked about). The summary is cut to 300 characters and secrets (API keys, tokens, bearer headers, `NAME=value` for secret-looking names, long random strings) are removed before writing; the full input is never logged. Files are `decisions-YYYY-MM.ndjson` in `~/.open-dots/decisions` (override with `DECISION_LOG_DIR`; the desktop app uses its data folder), mode 0600 in a 0700 folder. Months that ended more than `DECISION_LOG_RETENTION_DAYS` (default 180) ago are deleted, at most once an hour. A write that fails is logged and ignored: an approval never fails because of it. Read it with `GET /api/v1/decisions?limit=50&before=&event=denied&thread_id=3`. The log is not tamper-proof: anyone who can write to that folder can change it. **CSV export.** `GET /api/v1/decisions/export.csv` takes the same `event` and `thread_id` filters (and `limit`, at most 10000) and returns the log newest first as CSV. Every cell is redacted again, and a cell starting with `=`, `+`, `-`, `@` or a tab is prefixed with `'`, so a spreadsheet shows it as text instead of running it. **Health scan (sentinel).** A read-only scan of recent conversations: `GET /api/v1/sentinel/scan?hours=24` (1 to 2160), or from `backend/`, `uv run python -m app.services.sentinel [--hours 24] [--json]`. It reads stored thread events and the decision log, changes nothing and calls no model, and reports, worst first: **retry loops** (the same tool failing the same way 3 or more times in one turn), **repeated denials** (the same call denied 2 or more times in one conversation, so Claude kept asking for what you refused), **tool errors** grouped by tool and error, and **failed answers** grouped by error code (stops and shutdowns are left out). An issue never holds tool output: its title redacts quoted text, URLs, email addresses, paths, ids and numbers; structured errors use only safe categories, HTTP status codes and allowlisted field names. Titles are cut at 160 characters. A failed shell command is grouped only by exit code and an error class (`command not found`, `no such file`, `timed out`, ...). Each issue points at its evidence (an event id or a decision id). The scan keeps no state and writes no file. ## The Chat page Open `/chat` (the "Open chat" link on the home page). Set `LOCAL_EXECUTION_ENABLED=true` on the backend and prepare at least one engine: a signed-in Claude or Codex CLI, an ACP command, or an OpenAI-compatible endpoint. **Settings → Engines** shows what is ready and why an engine is unavailable; Claude being unavailable does not block another engine. With `LOCAL_TOOLS_ENABLED` off turns are chat only. The OpenAI-compatible engine is always chat only. See [Engines](ENGINES.md). - **Conversations.** The list on the left shows every conversation, newest activity first. "New chat" starts an empty one; the conversation is created when you send the first message, and its address (`/chat?thread=`) can be bookmarked. On a narrow window the list is behind the menu button. - **Engine and model.** For a new chat choose an engine and a model, or pick an agent to use its engine and model. Suggestions come from the engine catalog; custom model IDs are accepted where supported. ACP uses its CLI’s own configured model. An existing conversation keeps its engine; change its model from the header while it is idle. Leave the model unset to follow its agent on the same engine, or the engine default otherwise. A conversation model override wins over the agent. Agent engine changes apply to new conversations. - **Answers and tool calls.** The engine’s answer appears as it is written. Each tool call is one step you can open to see its exact input and its output (long output is cut for display, with a button to show all). - **Approvals.** A call that needs your permission shows a card with exactly what will run, and does not run until you press Allow. Deny, or no answer, runs nothing. Until you answer, the message box is closed. For a shell command the card also offers **Always allow this command**: it is allowed now, and exactly that command runs without asking in this conversation's folder from now on; the card then offers **Undo**. If it cannot be saved (a credential in it, the safety floor) the card says it was allowed once only. A call a saved rule allowed shows "Allowed by a saved rule, without asking" with **Stop always allowing**. The header shows the conversation's approval mode (its agent's, or Ask first for a plain chat) when tools are on. - **Approvals panel.** The **Approvals** button in the header (also a section of the desktop Settings page) has three tabs: **Always allowed** lists the saved rules, by default the ones that apply in this conversation's folder, and adds (exactly, or starts with; one folder or every folder) and removes them; **Decisions** pages through the decision log, newest first, filtered by event and conversation, with **Export CSV**; **Health** runs the read-only scan described above. - **Reload or restart.** Reloading the page, or restarting the backend, shows the same conversation. The selected engine session continues where that engine supports it. If the backend stopped while an engine was working, the page says the message was not resumed or sent again; send it again if you still want it. - **A lost session.** If the selected engine can no longer find a conversation's session, the page explains it and offers "Start a new session". The messages stay visible, but the new engine session does not have them as context. - **Connection.** If the page loses the backend it shows "Reconnecting…" and catches up by itself; nothing is lost or shown twice. - **Stop.** While an engine is answering, the send button is Stop. The engine is asked to stop, what it had written is kept and marked as unfinished, a question waiting for your answer is cancelled (nothing runs), and any tool call that had not finished is marked. The conversation continues with its engine session where supported. If the answer had already finished, nothing is lost. - **Rename, archive, delete.** Each conversation has a menu (the dots next to it). Renaming does not move it in the list. Archiving hides it from the list; "Show archived" shows archived ones again, where Unarchive brings one back. Deleting asks first and removes the conversation's messages, tool calls and approvals from this app's database, and that cannot be undone. Archiving and deleting are refused while an answer is running: stop it first. Delete removes this app's own records of the conversation and nothing else; it is not a secure erase. It does not delete session data retained by the selected engine, files written in the workspace, copies or backups of the database, or the older Local Claude card's task history. The database may also keep the deleted text in unused space inside its file until that space is reused or the file is compacted. - **Formatted answers.** Answers are shown as Markdown: headings, lists, tables, quotes, bold and italic, inline code, and code blocks with a Copy button. Raw HTML in an answer is shown as text, never run, and only `http`, `https` and `mailto` links are clickable (they open in a new tab). A half-written answer renders as it stands and settles as more arrives. - **Agents.** When the backend supports named agents, the header has an **Agents** button and, for a new chat, an agent picker. The Agents panel lists, creates, edits and archives agents: a name, instructions, an engine and model (suggestions or a custom ID when the engine supports it; unset uses the engine default), a workspace folder, and an approval mode: **Ask first** (writes and commands ask), **Accept edits** (file edits go ahead, commands ask) or **Plan only** (nothing is changed). Each sidebar agent has a **Delete** button; the Agents panel also has **Delete** for active and archived agents. Delete asks for confirmation; it removes the agent, notes, skills and computer. Its conversations remain in history but become unlinked and continue without the agent's instructions. Its workspace folder stays, and routines assigned to it fail and pause. Each agent also has a title and description (for you; not sent to Claude), **Duplicate** (same settings, no notes, named " copy"), **Pin** (pinned agents list first), **Notes** and **Export**. The panel has **Import…**; see [Agent notes and agent files](#agent-notes-and-agent-files). A change to the model applies on the next message to conversations following the agent on the same engine; a conversation’s explicit model overrides it. Approval-mode changes apply on the next message. Changing an agent’s engine affects new conversations; existing ones retain their original engine. Instructions and notes are read when a conversation starts: the Claude CLI keeps a session's system prompt when it resumes it (checked with Claude Code 2.1.302), so a change to them applies to conversations started after it. A conversation shows its agent's name in the list and the header. With an older backend that has no agents the buttons and picker are simply absent. - **Search.** Search conversations from the box above the conversation list. It searches titles and user/assistant messages, with archived-conversation and agent filters; tool output is not indexed. See [search details](EVERYDAY.md#conversation-search). - **Where it is stored.** Conversations, including what Claude read, wrote and ran, are in the database, as described under [Durable threads](#durable-threads-experimental); the page itself keeps nothing in the browser. ## Agent computers (experimental) An agent can run its commands and file edits in its own Docker container instead of on this machine. Full design: [COMPUTERS.md](COMPUTERS.md). 1. **Docker.** Install Docker (Docker Desktop, Colima or Docker Engine) and start it. `docker info` must answer; the Computer panel and `GET /api/v1/computers/health` say why when it does not. Open Duet finds `docker` on `PATH` and in the usual install folders; set `OPEN_DOTS_DOCKER` to its full path otherwise. 2. **Give the agent a computer.** Agents panel → **Computer** → choose **No network** (the default) or **Internet**, then **Give it a computer**. This only records the choice. 3. **Get the image.** **Download image** pulls the pinned default (`python:3.12-slim-bookworm`, about 45 MB). Nothing is ever downloaded without this step. To use an image you already have, put its reference in **Image** first (a `name@sha256:…` digest pins it); see [choosing another image](COMPUTERS.md#the-default-image-and-choosing-another). 4. **Start** (or just send a message: a turn starts a stopped computer and makes a missing one from a local image, unless **Start when a turn needs it** is off). What changes for that agent: the host's tools are off in its turns; it uses the computer's `exec`, `read_file`, `write_file` and `list_files`, with its workspace folder at `/workspace`. Every call still asks you (reads are allowed without a card; Accept edits also lets writes through; Plan only refuses writes and commands), saved command rules apply to `exec` too, and the cards carry a "computer" badge. Settings: CPUs, memory, processes and idle minutes apply at once; image and network apply after **Recreate**, which keeps the workspace folder and loses everything else inside the container. **Remove computer** removes the container (never the folder) and the agent goes back to working on this machine; removing the agent removes its computer. If Docker is not running, the agent's turns fail with a clear message instead of running on this machine. After updating, run `uv run alembic upgrade head` (`scripts/dev.sh` and the desktop app do). It adds the `computer` table and changes nothing else. ## Agent notes and agent files **Notes.** Every named agent keeps notes between conversations in one Markdown file, `MEMORY.md`, in its own folder: `agents/` under `LOCAL_WORKSPACE_ROOT` (default `~/.open-dots/local/workspace/agents//MEMORY.md`). The file stays there even when the agent works in a project folder, so a project never gets an agent's notes in it. When a conversation starts, the first 200 lines, then at most 24 KB of those, are added to the agent's system prompt after its instructions (later changes to the file reach conversations started after them; the Claude CLI keeps a session's system prompt when it resumes it); anything past that does not load, and the agent is told the file was cut. The agent is told where the file is and asked to keep it short and current; when the file is inside its workspace it can edit it with its file tools, which ask for approval like any other write. The notes are given to it as background that never grants permission for anything. - **Secrets.** Anything that looks like an API key, token, `password: ...` value, bearer token or private key block is replaced with `[redacted]` when the notes are saved (through the API, the panel, starter notes or an import) and again when they are loaded into a turn, so a key the agent once copied into the file does not keep travelling. Saves report how many things were replaced. - **The file.** It is written atomically (a crash leaves the old file) with owner-only permissions (0600). A link put in place of the file is never followed. At most 256 KB can be saved. - **Editing.** In the Agents panel, **Notes** opens the file with a gauge of lines and size against what loads (amber from 80%, red once anything is cut). Saving sends the version you opened; if the agent changed the file meanwhile, the save is refused and you choose between loading its version and overwriting it with yours. **Use notes in this agent's conversations** (also in the edit form) switches loading off without deleting the file. A new agent can be given **Starter notes**. - **Lifecycle.** A duplicate starts with no notes. Deleting an agent deletes its `MEMORY.md` (its folder stays, because its conversations may use it), and a new agent never inherits a file left behind at its id. Archiving keeps the notes. - **API.** `GET /api/v1/agents/{id}/memory` returns `text`, `hash`, `lines`, `bytes`, `loaded_lines`, `loaded_bytes`, `truncated`, `max_lines`, `max_bytes`, `enabled` and `path`. `PUT /api/v1/agents/{id}/memory` with `{"text": ..., "expected_hash": ...}` saves; a stale hash is 409 `memory_changed` with `current` and `current_hash`; leave `expected_hash` out to overwrite on purpose; more than 256 KB is 413 `memory_too_large`. `memory_enabled` is a field of the agent (`PATCH /api/v1/agents/{id}`), and `starter_notes` is accepted by `POST /api/v1/agents`. **Agent files.** **Export** in the Agents panel (or `GET /api/v1/agents/{id}/export`) saves an agent as one `.agent.json` file to keep or to give to someone. It holds only the name, title, description and instructions, plus the notes and topic notes as starter notes when you tick **Include its notes** (`?include_notes=true`; off by default, because notes can hold private details). It never holds the model, provider, workspace folder, approval mode, pin, conversations or anything about this computer. Text that looks like a secret is replaced before the file is written, and the panel lists the parts where that happened (`redacted` in the response). **Import…** (or `POST /api/v1/agents/import` with `{"document": , "include_notes": true}`) adds a new agent from such a file. Only those fields are read; anything else a file says is ignored, so an imported agent always starts on **Ask first** with the default model and its own folder. A name already in use gets a number (`Reviewer 2`). Imported notes are redacted again and each file written is a History row by *Imported agent file* that can be undone; topic notes too large for the file are left out of an export and listed (`skipped`). A file that is not an agent file, or has an invalid topic name, is refused with 422 `invalid_agent_file`. After updating, run `uv run alembic upgrade head` (`scripts/dev.sh` does). It adds the `agent.memory_enabled` column (on for existing agents) and changes nothing else. **Topic notes, recall and compaction.** Under an agent's notes, **Topic notes** edits `memory/.md` files (longer notes about one subject; only their names load into every conversation), **Proposed** lists lines picked from your quiet conversations for you to accept or reject, and **History** lists changes with **Undo**. **Recall before each message** (on by default) adds matching topic-note and earlier-conversation passages in front of your message and shows a **Recalled** chip in the transcript. On an OpenAI-compatible endpoint, give its model's context window with `context_tokens` (`PUT /api/v1/engines/openai_compat/config`) so long conversations are folded before they overflow; the transcript then shows **Earlier messages summarized**. Run `uv run alembic upgrade head` for the `agent.recall_enabled` column. Details in [MEMORY.md](MEMORY.md). ## Routines A routine runs a prompt as a new conversation of one of your named agents on a schedule, by webhook, or when you press **Run now** (Chat page → **Routines**). It needs the same setup as the [Chat page](#the-chat-page) (`LOCAL_EXECUTION_ENABLED=true` and a ready engine) and no Boat key. The backend must be running: the scheduler is a background loop in it, so a stopped backend or a sleeping computer runs nothing, and occurrences it misses follow the catch-up rule. Full behaviour, states and API: [ROUTINES.md](ROUTINES.md). | Setting | Default | What it does | |---|---|---| | `ROUTINE_POLL_SECONDS` | `5` | How often the scheduler looks for due occurrences, waiting approvals, run limits and finished runs. | | `ROUTINE_WEBHOOK_MAX_BYTES` | `65536` | Largest webhook body accepted (larger is 413). | | `ROUTINE_TELEGRAM_CHAT_ID` | empty | With `TELEGRAM_BOT_TOKEN`, routine notifications are also sent to this chat. Empty: in-app notifications only. | **Approvals.** A routine run never approves by itself. It uses the stricter of the routine's approval mode and its agent's; when it needs you it shows the usual card in its conversation, the run shows **Needs your approval**, and the bell gets a notification. If nobody answers within the routine's approval wait the request is denied. **Webhooks.** **Add webhook** shows a URL and a secret once (only a hash is stored; **New webhook secret** replaces it). Call it with `Authorization: Bearer `. The main backend answers `POST /hooks/{id}` for callers on this machine. To receive webhooks from elsewhere, run the hooks-only ingress and put a tunnel in front of that port only: ``` cd backend && uv run uvicorn app.ingress:app --host 127.0.0.1 --port 8788 ``` After updating, run `uv run alembic upgrade head` (`scripts/dev.sh` and the desktop app do). It adds the `routine`, `routine_run` and `notification` tables and changes nothing else. ## Durable threads (experimental) Conversations that survive a page refresh and a backend restart, continuing the selected engine session where that engine supports session continuation. The [Chat page](#the-chat-page) is its interface; the older Local Claude card on the home page still runs one prompt at a time. Chat needs `LOCAL_EXECUTION_ENABLED=true` and a ready engine. Tools and approvals follow `LOCAL_TOOLS_ENABLED` and the selected engine's capabilities; see [Engines](ENGINES.md). After updating, run `uv run alembic upgrade head` (`scripts/dev.sh` does). It adds four tables (`thread`, `turn`, `event`, `approval`) and changes nothing that exists. | Endpoint | What it does | |---|---| | `POST /api/v1/threads` | Start a conversation. Optional `provider` (default `claude`), `model` and `title`. | | `GET /api/v1/threads`, `GET /api/v1/threads/{id}` | List conversations, newest activity first; read one with its turns. | | `POST /api/v1/threads/{id}/turns` | Send a message, `{"prompt_text": "..."}`. Answers 202 once the turn has started; the answer arrives as events. 409 `turn_running` while a turn is running, 409 `session_broken` (see below), 503 `provider_unavailable` if the CLI is not ready. | | `GET /api/v1/threads/{id}/events?after=N` | Stored events after number N, in order. | | `GET /api/v1/threads/{id}/events/stream` | Server-sent events. Each stored event has `id: `; a browser that reconnects sends `Last-Event-ID` itself and receives exactly what it missed (or pass `?after=N`). `text.delta` frames are a typing preview with no id that is never stored: clear it when the `assistant.message` event arrives. | | `GET /api/v1/threads/{id}/approvals?status=pending`, `POST /api/v1/threads/{id}/approvals/{approval_id}` | Tool calls waiting for you, with the exact input; answer `{"decision": "allow"}` or `{"decision": "deny"}`. An approval that was already answered, expired, or whose turn is no longer running answers 409 `approval_not_waiting` and authorizes nothing. | | `POST /api/v1/threads/{id}/stop` | Stop the running answer. 202 once the selected engine was told to stop; the end arrives as a `turn.cancelled` event. 409 `no_running_turn` if nothing is running, 409 `turn_not_stoppable` if the turn is running in another backend process. | | `PATCH /api/v1/threads/{id}` | Rename and/or archive: `{"title": "..."}` (1 to 200 characters), `{"archived": true}` or `false`. 409 `turn_running` when archiving while an answer runs. | | `DELETE /api/v1/threads/{id}` | Delete the conversation's turns, events and approvals from this app's database (204). 409 `turn_running` while an answer runs. Not a secure erase: it does not touch engine-owned session files or anything outside the database (see the Chat page section). | | `GET /api/v1/threads?include_archived=true` | The list, with archived conversations included (they are left out by default). | | `POST /api/v1/threads/{id}/session/reset` | Reset the selected engine session for the thread (see below). Not allowed while a turn is running. | Events, in the order they happen: `turn.started` (it carries the prompt), `assistant.message`, `tool.started`, `approval.requested`, `approval.resolved`, `tool.completed`, then one of `turn.completed`, `turn.failed` or `turn.interrupted` (`turn.cancelled` is what Stop produces); and `session.reset` when a session is reset. What to know: - **Restarts and crashes.** Everything stored survives. A turn that was running when the backend stopped is marked `interrupted` and is never run again. Quitting the backend cleanly does this at once; after a crash it happens about a minute later, when the turn's heartbeat expires, and until then the thread reports a turn as running. The engine may not remember the call it was about to make, so include any important state in your next message. The text of an answer that was still being typed at that moment is not saved; only completed answers are. A turn that fails while the backend keeps running keeps its partial answer, marked `incomplete`. - **A lost session.** If the selected engine can no longer find a thread's session, the turn fails with `session_resume_failed` and the thread becomes `broken`. The app never starts a fresh session on its own: the engine would answer without the earlier context while looking like a continuation. Reset the session explicitly; the stored transcript stays visible, but the engine no longer has it as context. - **Approvals** are stored with the exact input that was shown and are valid only while their turn is running. An Allow reaches Claude only if the turn still belongs to the backend process that is running it. - **Stored transcripts.** Events can contain what the selected engine read, wrote and ran, including file contents and commands. They are stored in the database (the SQLite file, or PostgreSQL); there is no retention period yet. A conversation can be deleted from the Chat page (or `DELETE /api/v1/threads/{id}`), which removes this app's records of it but not data retained by the engine or files in its workspace. It is not a secure erase (see [the Chat page](#the-chat-page)). Keep the database out of shared or synced folders. - **One backend process for the live parts.** The typing preview and a live Allow or Deny answer are handled in the memory of the process running the turn. With several backend processes, stored events still reach every stream within about a second, and an answer recorded by another process is noticed within about a second. - **Databases.** SQLite and PostgreSQL are covered by the current backend test suite and CI. The local live checks recorded here do not cover every engine/database/platform combination. - The heartbeat and expiry times (`THREAD_HEARTBEAT_SECONDS`, default 10, and `THREAD_LEASE_SECONDS`, default 60) are internal tuning values; you should not need to change them. - A turn whose engine does nothing (no event, no typing) for `TURN_STALL_SECONDS` (default 600) while not waiting on an approval is stopped and fails with `engine_stalled`; set `0` to turn this off, or raise it for long silent tool runs. Only one backend can use a SQLite database at a time: a second one refuses to start and names the process using it. See [RELIABILITY.md](RELIABILITY.md). - Current CI covers SQLite and PostgreSQL; focused live engine checks cover Claude and Codex on macOS. This is not a compatibility claim for every supported engine or platform. ## Desktop app (from a source checkout, experimental) An Electron window around the local backend: you open one app instead of starting Python, the API and the web UI yourself. You run it from a clone of this repository, or install it from an unsigned installer (see **Installers** below). It uses local agents and SQLite, and needs no `.env` file. The older Boat schedules and Telegram bot are separate optional paths; local routines are included and do not need Boat or Telegram. **From a source checkout you need:** Node.js 22 or newer, [`uv`](https://docs.astral.sh/uv/getting-started/installation/) (it downloads the Python the backend needs, so you do not install Python yourself), and at least one ready engine. For Claude, install and sign in to the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code); alternatively use Codex or configure ACP/an endpoint in Settings. Run the commands below in the `desktop/` folder. **The short way:** `npm run app` does whatever is missing, then opens Open Duet: it sets up the first time, rebuilds the page when it is missing or older than its source, and otherwise just starts. (`npm run app -- dev` is the same in dev mode.) The four commands below are what it runs, and what to use when you want one step on its own. | Command | What it does | |---|---| | `npm run setup` | Installs everything: the backend's Python environment (`uv sync --locked`), the page's dependencies and Electron, including its program file. Run it once, and again after the dependencies change. | | `npm run build` | Builds the page the app shows, as plain files. Run it once, and again after the page's code changes. | | `npm run start` | Opens Open Duet. | | `npm run dev` | The same, with its own data folder and window title, so developing never touches the data you use. | | `npm run doctor` | Checks source prerequisites and finds Claude/Codex executables without starting the app or modifying data. | `start` and `dev` never install or build anything. If something is missing they say which command to run and stop. (If the page's code is newer than the last build, they tell you, and open the older build.) **Diagnostics.** From `desktop/`, run `npm run doctor` before a first launch or when setup fails. It reports the Node requirement, missing Electron/Python/UI files, a stale UI build and CLI executables on `PATH`, with the command to fix each problem. Exit 0 means source prerequisites are present; exit 1 means setup is incomplete; unknown options exit 2. For machine-readable output, use `npm run --silent doctor -- --json`. Finding a CLI does not prove sign-in or model access: check **Settings → Engines** for authenticated readiness and configured ACP/API endpoints. Doctor reads files only; it does not start CLIs, inspect credentials, install anything or change app data. **Build disk space.** Before building, `npm run package` requires at least 2 GiB available to the current user on each volume used by the desktop, frontend, backend and OS temporary directory. This is a conservative minimum, not a guarantee that a build will fit. If the check fails, free space or move the checkout/temporary directory to a larger volume, then retry. The command does not delete previous outputs. A passing check does not verify the installer. **Installers (experimental, unsigned).** `npm run package` (in `desktop/`, after `npm run setup`) builds the page, freezes the backend with PyInstaller (`backend/build.py`, needs `uv`) and makes an installer for the operating system it runs on with electron-builder: `desktop/release/Open-Duet-Setup-.exe` on Windows (NSIS, x64) and `desktop/release/Open-Duet--arm64.dmg` on macOS. Pushing a `v*` tag attempts both builds with GitHub Actions (`.github/workflows/release.yml`). The `v0.7.0` prerelease includes a Windows x64 installer and macOS arm64 DMG; the macOS installer omits native desktop preview because it is not signed and notarized. The feature remains available from a source checkout when the reviewed offline bundle is staged. Both installers carry their own backend and include the assisted updater. Installers need no Python but do need at least one ready engine. CLI engines must be installed and signed in, while ACP and API-based engines can be configured in Settings. `npm run smoke:packaged` starts the current platform's unpacked Windows or macOS arm64 app with no Python on `PATH` and a unique disposable profile. Pass an installed executable path after `--` to check another installation. The check requires a created SQLite database and HTTP 401 refusals for unauthenticated page/API requests; it bounds network waits and stops the launched process group even on failure. Add `--keep` to retain the profile. This is a packaging/startup check; exercise the installed UI separately. The installers are not signed or notarized, so Windows SmartScreen and macOS Gatekeeper will warn; PyInstaller does not cross-compile, so each OS needs its own build. **First start.** The app creates its database and encryption key, then opens a setup flow in Settings. Choose an installed, signed-in engine (or configure ACP/API access), a default workspace root and whether tools are enabled. The flow explains Ask first, Accept edits and Plan only, and can create a starter agent with your engine/model and approval mode. Finishing selects that agent in Chat; without a new agent, Chat uses the selected engine/model. Completion is stored in the app database and survives a reload or restart. **Set up later** enters Chat without changing your preferences or creating an agent. Use **Run setup again** in Settings to revisit the choices. Existing agents are kept, and replay does not create another agent by default. Native folder selection is available from the workspace step. **What the app does for you** - **Encryption key.** Made on first start and kept with the operating system's secret storage (Windows DPAPI, the macOS Keychain, or the Linux keyring) in `secrets/encryption-key.v1` in the data folder. It is never written as plain text and never replaced: if the stored key cannot be read, or the stored credentials cannot be opened with it, the app stops and tells you instead of making a new one. This protects against other user accounts and a copied disk; it does not protect against software running as you. Where there is no secret storage (a Linux session without a running keyring) the app does not start rather than keep the key in a plain file. - **Database.** SQLite, in the `data` folder, created and updated by the app. Before an update that changes the database, the app copies it to `data/backups/` (the newest five copies are kept). A file that is not one this app made is refused and left alone. When the app stops, the write-ahead log is folded into the file, so the file on its own holds everything. - **Access.** Only the app's own window can use the backend. Each start makes a fresh random token, which the backend receives before it listens and the window adds to its own requests; a browser tab, another program or another website that finds the address gets nothing (a browser tab is told to use the app window). The token is never written to a file, a log, the environment or the page. There is no way to switch this off, in `dev` either. The address and port are chosen by the operating system on every start. - **Settings.** Not read from backend environment variables or any `.env` file. SQLite and local execution stay on, and the workspace stays under the data folder (`local/workspace`). The Settings page lets you choose the Claude executable and default workspace root, switch local tools on or off, and change answer/approval timeouts and the output limit. Tools are on by default; Claude's existing permission rules still apply, as described above. **Where things are.** The data folder is `Open Dots` (or `Open Dots (dev)` for `dev`) in your operating system's application-data folder (on Windows, `%APPDATA%\Open Dots`). In it: `data/` (the database, its backups, Claude's workspace in `local/`), `secrets/`, and `logs/backend.log` (the backend's log, also where it says why it did not start). To remove the app's data, close it and delete that folder. Claude's own copy of each session stays in your `.claude` folder, as for the Chat page. **Settings.** Follow **Settings** in the Chat header (also available when Claude is missing). **Engines** is available in both web and desktop Settings: inspect status, refresh probes, save an ACP command/display name, an OpenAI-compatible base URL/default model/API key or an OpenRouter API key/default model, and forget saved configuration. The endpoint key is encrypted and never returned. An empty key keeps a saved key only for the same URL; changing the URL removes it unless you enter it again. **Forget settings** removes the saved configuration and credential. Sign in to CLI engines in a terminal first. See [ENGINES.md](ENGINES.md). Desktop also exposes Claude preferences and storage. The Claude section has six preferences: executable path, workspace root, local tools, answer timeout, approval timeout and answer output limit. **Browse** opens a native file/folder picker; you can also enter a full path. Leave the executable empty for detection through `PATH` (Windows npm `claude.cmd` shims are supported), or the workspace empty for the desktop default. Timeouts are 30–3600 seconds; output is 1024–16000000 bytes. Each field has its own **Save** and **Discard**, source, effective value, default and apply rule. Settings are stored in the app database, with versions that reject a stale save rather than silently overwrite another change. Changes to the executable, tools, workspace and approval timeout also record their old and new non-secret values in a settings audit table. **Reload saved settings** discards unsaved edits and reads the latest versions. No restart is needed: executable, tools, answer timeout and output limit apply to the next turn; workspace changes affect new conversations only; approval timeout changes affect new approvals only. A running answer keeps its configuration, and an approval already waiting keeps its deadline. **Workspace identity.** New conversations use the `workspace` subfolder of the chosen root. Each conversation stores its full resolved workspace path, so changing the default never moves existing sessions. If that folder disappears or becomes inaccessible, sending another message is refused; the conversation stays readable and the app does not recreate or switch its folder. Restore the original folder/access to continue. When upgrading an existing database, conversations are pinned to the workspace in use at upgrade time and the launcher makes its usual backup before migrating. **Validation and recovery.** Invalid stored settings do not prevent startup. An invalid tools value disables tools; an invalid executable makes Claude unavailable without silently choosing another; an invalid workspace blocks new conversations. Invalid numeric limits use bounded defaults with an error shown beside the setting. Correct and save the value in Settings. Storage shows the data folder, database, backups and OS-backed encryption-key status, all read-only. Settings editing requires the desktop launch token plus local access checks. Headless installs keep their environment/`.env` configuration. **Claude not found or not signed in.** The Chat page links to Settings. Install Claude or sign in with `claude auth login` in your terminal, then choose **Check again**. **Check Claude status** in Settings checks the saved executable and login, not an unsaved draft. `OPEN_DOTS_CLAUDE_PATH` remains a launcher fallback when no executable preference is stored; a stored value takes precedence. An empty stored path explicitly selects automatic detection. An app opened from Finder or the Dock gets only the system `PATH`, so the launcher adds the usual install folders that exist (`~/.local/bin`, `~/.npm-global/bin`, `~/.claude/local`, `/opt/homebrew/bin`, `/usr/local/bin`) to the end of the backend's `PATH`; for a CLI anywhere else, choose its executable in Settings. **One more launcher variable**, optional: `OPEN_DOTS_DATA_DIR` (a full path) uses another data folder, for tests or a second copy. Like `OPEN_DOTS_CLAUDE_PATH` it is read by the launcher, not by the backend, and is not a `.env` setting. **Quitting.** Closing the window stops the backend. If the app is killed, the backend notices and stops within seconds. A message Claude was answering at that moment is interrupted and is not sent again (see [Durable threads](#durable-threads-experimental)). **Limits of this version** - Verification is feature- and platform-specific, not full platform certification. CI covers backend, web UI and desktop checks; focused macOS source-build checks cover native preview and Docker computer flows. Linux desktop packaging/runtime and a complete upgrade from a published release remain unverified. Linux also needs a running keyring for OS-backed secret storage. - Updates are assisted, not automatic: the `v0.4.0` app checks GitHub releases (15 seconds after start, then every 6 hours; set `OPEN_DOTS_DISABLE_UPDATE_CHECK=1` to stop), and Settings → App updates can download an installer for your computer, check its SHA-256 digest and open it when you click. The app does not replace itself. The release-to-release install path has not been verified end to end. Settings and Browse are available; storage locations cannot be changed here. - One app per data folder (a second start brings the first window forward), but this is the app's own lock, not a lock on the data folder: do not point two copies at one folder with `OPEN_DOTS_DATA_DIR`. - If the backend stops while the app is open, the app tells you and closes; it does not restart it. - The page's address (and so its browser storage) changes on every start. The page keeps nothing there that matters. - `npm run e2e:smoke -- --keep` (in `desktop/`) opens the source app twice with one fresh, unique profile. It checks window rendering, authenticated window API access, refusal of external requests without the launch token, SQLite/key persistence and clean backend shutdown/restart. It makes no model requests. It supports macOS, Windows and Linux with a graphical session and working OS secret storage; run setup/build first. Each run uses its own data folder and CDP port. `--keep` retains screenshots and `result.json`; failures always retain evidence. This is source-app coverage; installer, updates and live-engine behavior need separate checks. - `npm run e2e` (in `desktop/`) runs the app for real: it opens a window, and its `chat` part makes a few short requests with your Claude. Those older detailed groups remain Windows-only. `npm run e2e -- upgrade` is optional and only runs when named: it starts the previous release (taken from Git history, prepared once under your temp folder), then checks that upgrading keeps an existing Claude conversation in its original workspace after you change the default one. It needs Claude signed in (two short requests), `git`, `tar` and `uv`, and skips with a reason if any is missing. The conversations it creates stay in Claude's own session store; the test never deletes anything there. The source checkout and installer have separate setup and verification limits. The current source includes the assisted update flow; see the notes above for the tagged-release boundary. ## Choosing an AI during desktop setup Setup detects ready engines and selects one in this order: Claude, Codex, Cursor, OpenCode, Gemini, OpenRouter, then any other ready adapter. A person's working selection is preserved when status refreshes. Engine and model choices are under **Advanced: engine and model**. When none is ready, **Connect an AI** shows connection instructions and a **Check again** button; Continue waits for a ready engine. Skip and replay remain available. This setup flow was checked in Chromium at desktop and phone widths with scripted engine responses. Real preset accounts and packaged first run were not checked for this change.