# 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 today. Both the desktop and the web client authenticate as the **same Zitadel user**. **Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`). `RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop stamps its own `sub` (decoded from the access token) onto everything it pushes, and defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent `ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for multiple users **without enforcing isolation client-side** — the server remains the authority that scopes every request by the token's `sub`. When the server goes multi-user it should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`. **Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer `https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS` env var is gone). The access token carries the role in the claim `urn:zitadel:iam:org:project:roles` (or the project-scoped variant `urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g. `{ "user": { "": "" } }`. The desktop OIDC client (id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued after login/refresh includes the claim automatically — no extra scopes are needed. Granting/revoking access is purely a Zitadel role grant, nothing app-side. ## 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 `) that carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid token without the role → `401`. No anonymous access (imported tasks can trigger code execution on the user's machine). The desktop client treats a `401` as: force a refresh-token exchange and retry once; if a freshly issued token is still rejected, it surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again. > **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed > `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)` > with the Zitadel authority/audience/client id to wire `JwtBearer` validation + CORS for > the web client origin. (`KunsZitadel` is server-side token *validation* only; the desktop > client acquires tokens via its own OIDC flow.) | Method & path | Caller | Body | Response | |---|---|---|---| | `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` | | `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` | | `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","ownerId"? }]` | | `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) | | `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` | `ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push and ignores pulled tasks owned by a different user; the server should derive/validate it from the token rather than trust the client value. 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.