ClaudeDo Online Inbox
Optional online mirror of the ClaudeDo desktop app's Idle task backlog. Lets the single owner view their lists and jot new tasks from a phone or browser. The desktop app (local, .NET/SQLite) remains the source of truth; this service only stores/serves data — it never executes tasks.
Live: https://claudedo.kuns.dev · API base: https://claudedo.kuns.dev/api
Governing rule
The online store mirrors exactly the desktop's Idle tasks. A task exists online only while it is Idle; queuing it on the desktop deletes it here. Running/Done/Failed/review tasks never appear online.
- Lists — desktop → online only (full-replace catalog).
- Idle tasks — two-way creation: the desktop pushes its Idle tasks (PUT) and pulls
web-created ones (
consumed=false→ import → consume).
Stack
Nuxt 3 (Vue 3, TypeScript, Bun) running as an SPA (ssr: false) with Nitro server routes as
the API, on shared PostgreSQL, behind Zitadel auth, deployed via Coolify.
- Web client login:
@kuns/zitadel-auth(vendored undervendor/). - API token validation:
joseagainst Zitadel JWKS. - DB access:
postgres(postgres.js), parameterized queries only.
API
Every /api/** route requires a valid Zitadel access token (Authorization: Bearer …)
belonging to the owner. Missing/invalid/expired → 401. No anonymous access.
| Method & path | Caller | Behaviour |
|---|---|---|
PUT /api/lists |
desktop | Body [{id,name}] = full catalog. Upserts all, deletes lists not in payload (cascades tasks). → 200 |
GET /api/lists |
web | → 200 [{id,name}] |
GET /api/lists/{id}/tasks |
web | → 200 tasks for the list; 404 if unknown |
POST /api/tasks |
web | Body {title, description?, listId}. Server-generated GUID, source=web, consumed=false. 404 if listId unknown. → 201 with the created task |
PUT /api/tasks/{id} |
desktop | Body {listId, title, description?}. Idempotent upsert (source=desktop on insert). → 201 (new) / 200 (existing) |
DELETE /api/tasks/{id} |
desktop | Idempotent. → 204 (even if absent) |
GET /api/tasks?consumed=false |
desktop | → 200 [{id, listId, title, description, createdAt}] web tasks not yet imported |
POST /api/tasks/{id}/consume |
desktop | Sets consumed=true. Idempotent. → 200; 404 if unknown |
Task ids are a shared GUID space: web-created ids are reused verbatim by the desktop on
import; all task writes are idempotent upserts keyed on id.
Zitadel configuration (for the desktop client)
Both the desktop and the web client authenticate as the same Zitadel user (the owner). Project ClaudeDo has two PKCE apps (both issue JWT access tokens):
| Web | Desktop | |
|---|---|---|
| Client id | 376787352019861775 |
376787352137302287 |
| App type | User-Agent (SPA) | Native |
| Auth method | PKCE (none) | PKCE (none) |
| Grants | authorization_code | authorization_code, refresh_token |
| Redirect | https://claudedo.kuns.dev/auth/callback |
http://localhost:8765/callback, http://127.0.0.1:8765/callback |
Desktop OnlineInbox settings:
- Issuer:
https://auth.kuns.dev - Client id:
376787352137302287 - Scopes:
openid profile email offline_access urn:zitadel:iam:org:project:id:376787351902355727:audoffline_access→ refresh token for headless re-auth.- the
…:audscope puts the project id into the token'saudso the API validates it.
- Flow: Authorization Code + PKCE on a loopback redirect for the initial interactive login, then refresh-token grant headlessly. Store the refresh token securely.
- API base URL:
https://claudedo.kuns.dev/api
Project id: 376787351902355727. Owner user id (sub): 365090688972947729
(mika@kuns.dev).
Environment variables
Server-only values are read from process.env at runtime (set them in Coolify):
| Var | Purpose |
|---|---|
DATABASE_URL |
postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/claudedo (shared PG, internal host) |
ZITADEL_ISSUER |
https://auth.kuns.dev |
ZITADEL_AUDIENCE |
accepted audiences (CSV): web id, desktop id, project id |
ALLOWED_USER_IDS |
owner sub allowlist (CSV) |
WEB_ORIGIN |
CORS allowed origin (https://claudedo.kuns.dev) |
Public web-client config is baked at build time (non-secret) via Dockerfile build args
(NUXT_PUBLIC_ZITADEL_ISSUER, NUXT_PUBLIC_ZITADEL_CLIENT_ID,
NUXT_PUBLIC_ZITADEL_PROJECT_ID); override with --build-arg if the ids change.
See .env.example for local development.
Development
bun install
# Point at a local/tunnelled Postgres and apply the schema:
DATABASE_URL=postgres://… bun run migrate
DATABASE_URL=postgres://… bun run dev # http://localhost:3000
# Tests (need a Postgres test DB):
DATABASE_URL=postgres://…/claudedo_test bun run test
Local API smoke without a Zitadel token: set AUTH_DEV_BYPASS=1 (dev mode only — it is
dead-code-eliminated from production builds).
Database
DB claudedo on the shared PostgreSQL instance. Schema (server/utils/schema.ts) is applied
idempotently on server startup by server/plugins/migrate.ts (advisory-locked) and via
bun run migrate.
Deployment
Coolify builds the Dockerfile (Bun build → Node runtime) on push to Gitea; Traefik routes
claudedo.kuns.dev with automatic SSL.