# ClaudeDo Online Inbox — API Contract & VPS build prompt Status: handoff doc. The **server side** (API + minimal web client) is built and deployed VPS-side by a separate Claude instance. This file is the source of truth for the contract both ends implement against. The desktop client in this repo is built to match it. --- ## 1. Concept ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is normally fully local (SQLite). The **Online Inbox** is an optional service that lets the single owner view their task lists and add new tasks from a phone/browser. The desktop app syncs against it. **Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing else. A task is present online only while it is `Idle` on the desktop. The moment the user queues it locally, the desktop removes it from the online store. Running / WaitingForReview / Done / Failed / Cancelled tasks never appear online. Sync directions (each one-way per entity → no conflict resolution needed): - **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog). - **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the desktop pulls down and then owns. Single user. Both the desktop and the web client authenticate as the **same Zitadel user**. ## 2. Idle backlog definition (desktop side) The desktop mirrors only "real" backlog items, not planning internals: - `Status == Idle` - `ParentTaskId == null` (no planning/improvement children) - `PlanningPhase == None` - `BlockedByTaskId == null` ## 3. Data model (Postgres) ``` lists id text primary key -- GUID supplied by the desktop; reuse verbatim name text not null updated_at timestamptz not null default now() tasks id text primary key -- GUID; SHARED id space (see below) list_id text not null references lists(id) on delete cascade title text not null description text imported boolean not null default false -- false = web-created, awaiting desktop pull -- true = desktop-owned (mirrored or handed off) created_at timestamptz not null default now() updated_at timestamptz not null default now() ``` **Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID. All task writes are idempotent upserts keyed on id. **`imported` flag = ownership.** - Web `POST /tasks` inserts `imported=false`. - Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST /tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop mirror. - `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition. It never touches `imported=false` rows (those are pending handoff). ## 4. Endpoints All endpoints require a valid Zitadel access token (`Authorization: Bearer `). Missing/invalid/expired → `401`. No anonymous access (imported tasks can trigger code execution on the user's machine). | Method & path | Caller | Body | Response | |---|---|---|---| | `PUT /lists` | desktop | `[{ "id", "name" }]` — the FULL catalog | `200` | | `GET /lists` | web | — | `200 [{ "id", "name" }]` | | `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) | | `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` | | `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt" }]` | | `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) | | `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description" }]` — full Idle set | `200` | Semantics: - **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload (cascades its tasks). Idempotent. - **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id. - **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task in the payload (insert with `imported=true`, or update), and DELETE any `imported=true` task whose id is not in the payload. `imported=false` rows are untouched. Idempotent. - All task ids are client-trusted within the shared space; the server never rewrites an id. ## 5. Reconcile loop (desktop, runs each poll cycle) ``` 1. PULL: GET /tasks?imported=false for each: if no local task with that id → create local TaskEntity { Id = remote.id, ListId = remote.listId, Title, Description, Status = Idle, CreatedBy = "online" } (skip + log if remote.listId has no local list) then POST /tasks/{id}/imported 2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}] 3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2) ``` Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local Idle set computed in step 3 and survive the mirror replace. ## 6. Minimal web client Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page. - Zitadel login. - Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`). - Add-task form → `POST /tasks`. - Mobile-first (main use: jotting ideas from a phone). - **Create + read only.** No editing, reordering, status changes, or deletes. ## 7. Security - Every route auth-gated (`401` on bad token); only static assets / login are public. - Validate `listId` on task creation; parameterized queries only. - CORS restricted to the web client origin. - Don't log task titles/descriptions at info level (user content). ## 8. Deliverables from the VPS build Report back so the desktop can be configured: 1. **API base URL.** 2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes, and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how refresh tokens are issued. 3. Any env vars / README. Out of scope server-side: task execution (the desktop runs Claude), any task state other than the Idle mirror, multi-user / sharing / notifications.