# wspc — build a personalized todo app > This guide teaches an AI — a coding agent (Claude Code / Codex) OR a general > chat assistant that can write code (Claude app, ChatGPT) — to INTERVIEW the > user about how they work, design a custom-field schema that fits them, then > generate a single-file todo web app they can open in a browser right away > (deploying to a static host is optional). The result is tailored to one > person, not a generic todo list. ## Read this first This document covers ONLY the todo-app build flow. The OAuth essentials your app needs are inline in the next section, so you do not have to leave this file to get login working. For the rest of the shared platform mechanics (domains, the full OAuth reference, single-page-app conventions), see the general guide: - → https://wspc.ai/llms.txt Before generating any REST call, also fetch the live todo schema and treat it as the final source of truth (this prose may lag the API): - todo OpenAPI: https://api.wspc.ai/todo/openapi.json Do not invent fields, endpoints, enum values, scopes, or error shapes the live schema does not list. ## OAuth 2.1 device flow (use these EXACT endpoints) Do not guess auth endpoints. They live under `/auth/oauth/...` on `api.wspc.ai` — NOT `/oauth/...`. All requests/responses are JSON (not form-urlencoded). The only scope is `wspc:full` — never request `todo`, `todo:read`, or any other scope; they are not issued. 1. Register a client ONCE, then cache `client_id` in localStorage. Device-flow clients still need a placeholder `redirect_uri` — use the RFC 6749 OOB sentinel: POST https://api.wspc.ai/auth/oauth/register content-type: application/json { "client_name": "My Todo App", "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob"], "grant_types": ["urn:ietf:params:oauth:grant-type:device_code"], "token_endpoint_auth_method": "none" } → { "client_id": "client_...", ... } 2. Start device authorization (JSON body — NOT query params, NOT a scope): POST https://api.wspc.ai/auth/oauth/device content-type: application/json { "client_id": "" } → { "device_code", "user_code", "verification_uri", "verification_uri_complete", "expires_in", "interval" } 3. Open `verification_uri_complete` in a new browser tab/window for the user. It already embeds `user_code`, so do NOT ask the user to type a code. Meanwhile poll the token endpoint every `interval` seconds: POST https://api.wspc.ai/auth/oauth/token content-type: application/json { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "", "client_id": "" } - 200 { access_token, refresh_token, expires_in, token_type, scope } — approved - 400 { error: "authorization_pending" } — keep polling - 400 { error: "slow_down" } — add 5s to the interval, keep polling - 400 { error: "expired_token" } — restart at step 2 - 400 { error: "access_denied" } — user denied; surface to UI Schedule each poll with `setTimeout` using the CURRENT interval (re-read it each cycle) rather than a fixed `setInterval` — a fixed interval cannot honor `slow_down`, which must actually lengthen the gap before the next poll. 4. `access_token` TTL is 15 minutes. Refresh with the standard grant (each rotation invalidates the previous refresh token); on refresh failure, clear tokens and restart the device flow: POST https://api.wspc.ai/auth/oauth/token content-type: application/json { "grant_type": "refresh_token", "refresh_token": "", "client_id": "" } Send all data/API traffic to `api.wspc.ai`; use `app.wspc.ai` only for the browser window that completes consent. ## The core idea A todo's only built-in fields are `title`, `description`, `status`, `due_at`, and hierarchy (`parent_id`). Everything that makes a todo app feel personal — priority, tags, contexts, effort, project area — lives in **user-defined custom fields** attached to a **type**. So a good app starts by learning what THIS user needs, then declares exactly those fields. Follow the three phases below in order. Do not skip the interview. ## Phase 1 — Interview the user Use your built-in way of asking the user questions (an asking/clarify tool, or just ask in chat). Ask a SHORT round — at most 5 questions — then stop. Do not interrogate; do not assume answers. Ask about GOALS and the experience the user wants — NEVER about technical implementation. Do NOT ask the user to decide how something is modeled: whether a box maps to `status` vs a custom field, what a field should be named, what its allowed values are, `string` vs `string_array`, enums, or any API shape. That modeling is YOUR job to infer. Example: if the user says "drag tasks into 'urgent' and 'not urgent' boxes", do NOT ask "should urgent be a status or a custom field?" — decide it yourself (here: a custom field, status stays `open`) and reveal that only at the Phase 2 schema confirmation. Phrase every question in plain user terms: what do you want to see, capture, track, or filter by — not in API terms. Presenting choices: whenever you offer multiple-choice options, the user must be able to answer outside the listed choices. PREFER your asking tool's built-in free-text / "other" answer (don't hand-roll a catch-all when the tool already provides one). ONLY if your tool cannot offer a free-text answer, add an explicit catch-all option yourself (e.g. "Something else — describe it"). Start from the GOAL, not from fields. Ask first, using your asking/choice tool so these are selectable options. Offer the concrete choices below (plus a free-text / catch-all per "Presenting choices" above). Present exactly: > "What kind of work do you want to manage with this todo app? > A. Work projects & tasks — deadlines, priorities, progress > B. Personal life & errands — shopping, chores, appointments, reminders > C. Studying / reading / coursework — progress, reading list, due dates > D. Software dev / bug tracking — status flow, severity, module" If the user instead gives their own answer (free-text or catch-all), treat it as their goal: ask what attributes they repeatedly care about and map each to a field, just like the goals below. Then ask 1–3 follow-ups that converge on the chosen goal. Each goal has typical questions and a typical schema — use them as a STARTING POINT, not a fixed template; adapt to what the user actually says: - A (work): how do you rank urgency (e.g. P0/P1/P2, high/med/low)? group by client / project name? → likely `priority` (string), `client` (string) - B (life): do you sort by where/when (e.g. @home, @errand)? → likely `context` (string_array) - C (study): track which course/subject? distinguish reading from tasks? → likely `subject` (string) and a `kind` (string) value to tell them apart - D (dev): severity? which module/component? → likely `severity` (string), `module` (string), and a `kind` (string) value for bugs vs tasks Also probe, only as relevant: | Dimension | Becomes | | --- | --- | | ranking / urgency | a sortable `string` field | | tags / contexts / people | a `string_array` field | | distinct kinds of item (reading vs task, bug vs chore) | a single `string` field (e.g. `kind`) labeling each item — NOT separate types | | extra notes per item (estimate, location) | a `string` field | | what they want to see first | the app's default sort / filter | ## Phase 2 — Design the schema, then confirm This is the FIRST point where technical detail appears. Keep Phase 1 free of it; here you infer the full mapping yourself and then present it for approval. Map the answers to a schema (the live OpenAPI is authoritative): - Keep it to ONE list — do NOT create new types. The app augments the user's EXISTING default type (see Phase 3) so their current todos all stay in one place and gain the new fields. If the user named distinct kinds of item (reading vs task, bug vs chore), model the distinction as a single `string` field (e.g. `kind`) whose value labels each item — never as separate types. - Each recurring attribute becomes a custom field: - single value (priority, estimate, location, client, kind) → `string` - multiple values (tags, contexts, people) → `string_array` - Field `key` must match `^[a-z][a-z0-9_]{0,63}$` and must NOT reuse reserved core names (`title`, `status`, `id`, `due_at`, `description`, …). - `key` and `type` are immutable once created (rename = remove + re-add). - v1 has only `string` and `string_array`. There is NO number / date / enum field type — model "priority high/med/low" as a `string` and enforce the allowed values in your app's UI, not the API. Mapping examples: | User said | Schema | | --- | --- | | "rank high / med / low" | `priority` : `string` (UI offers high/med/low) | | "tag with #work #home" | `tags` : `string_array` | | "track Bugs separately" | `kind` : `string` (UI offers bug/task), one list | | "note estimated minutes" | `estimate_min` : `string` | Then SHOW the user the proposed schema in plain language (each field and which UI element it becomes) and ask for confirmation BEFORE generating the app. Present the design directly — do NOT narrate your own rules or process to the user (no "I won't ask you about technical details", no "I translated your needs into a data structure / schema"); just show the design as if it were the natural next step. Because the app builds on the user's existing list, also tell them — plainly, benefit first — that their current tasks all appear here, that new attributes (e.g. priority) start empty on older tasks and can be filled in anytime, and that nothing in their existing tasks is changed or deleted (you extend what the list can hold, you don't edit their tasks). Don't call it "adding fields to your old tasks" — that sounds like altering their data. Ask via your asking/choice tool with two options: > 1. "Looks good — build it" > 2. "I'd like to change something" (let the user say what — use the tool's > free-text answer, or add this as a catch-all option if it has none, per > "Presenting choices" in Phase 1) If they choose to change something, revise the schema and confirm again. Only generate the app after the user approves. ## Phase 3 — Generate the app Produce a single self-contained HTML file (vanilla JS, no build step), deployable to any static host. Keep it a SINGLE page unless the user explicitly asked for multiple views/routes. Follow the SPA conventions below. Budget COMPLEXITY, not lines. Keep the JavaScript simple — no framework, router, build tooling, or state-management library; one in-memory todo array as the single source of truth (per "Make it feel instant" below). Past that, do not ration size: styling and markup are NOT capped — spend whatever it takes to make the app look like a finished product, not an API demo (see "Make it look designed"). Emit the app as a downloadable FILE — a real `.html` file via your host's file / artifact / canvas mechanism (e.g. a code file in ChatGPT, an artifact in the Claude app, a written file in a coding agent). Do NOT paste the hundreds of lines into a chat message, do NOT fret that "the code is long for a message", and do NOT ask the user whether you should make a file — just produce the downloadable `.html` directly. (The "Delivering the app" section below covers how to tell the user to open it.) On first load, after the access token is obtained, the app MUST in order: 1. Resolve the project AND its default type. Call `GET /todo/projects` → `{ projects: [...] }` (no params required); use the first project's `id` and read its `default_todo_type_id` — the type that already holds the user's existing todos. New users get a lazily-seeded "Default Project"; there is NO `is_default` field, so do not look for one. `project_id` is required on almost every other todo call. (If `default_todo_type_id` is absent — only during a brand-new account's bootstrap — list types with `GET /todo/types?project_id=` and use the seeded "Default" type.) 2. Augment that default type IN PLACE — do NOT create a new type. The app adds its Phase-2 fields to the user's existing default type so their current todos stay in one list and gain the new fields. Find the type in the `GET /todo/types?project_id=` list (`project_id` is REQUIRED — omitting it returns 400 `VALIDATION_ERROR`, like `/todo/items`; response is `{ types: [...] }`), then verify and augment: inspect its `custom_fields`; if any field from Phase 2 is absent, add it with `PATCH /todo/types/{id}`. `custom_fields` on PATCH is REPLACED WHOLESALE, so send the full array = existing fields (each `key`/`type` unchanged — those are immutable; you may only add new keys) + the new ones. Do not rename the type. Writing a value for a field the type does not declare returns 422 `UNKNOWN_CUSTOM_FIELD` — this verify-and-augment step is exactly what prevents that. Schema changes live in the APP, not in you — only the app holds the user's token. 3. Render todos with the chosen custom fields wired into the UI. List with `GET /todo/items?project_id=&type_id=` — scope to the default type so todos from other tools' types never leak in and get mislabeled (`/todo/items` supports a single `type_id` filter). Then: - a `string` priority → a colored badge + a sort control (`?sort_by=cf.priority&order=...`) - a `string_array` tags field → chips + a filter (`?cf.tags=`) - a `kind` field → sections or a filter that group items by value - other `string` fields → an inline label + an edit input - respect any `hide_core_fields` hints from the type Treat any non-2xx response as a failure: read the error body and surface it. Never assume a list field (`projects`, `types`, `todos`) exists on a response you did not check — that is exactly what turns a 400 into a confusing `undefined.find(...)` crash. ### Date-based "today" / "due soon" views: include overdue and undated items `due_after`/`due_before` match ONLY todos whose `due_at` is set and within range — **undated and overdue todos are excluded**. So a bare `?due_after=&due_before=` "today" view shows nothing on a fresh list and silently drops yesterday's unfinished tasks. For a personal "today" / "due soon" view, fetch open todos WITHOUT a due-range filter and bucket them client-side (overdue / today / upcoming / no date). Reserve `due_after`/`due_before` for explicit date-window reports. Compute "today" from the user's LOCAL date, not UTC: `new Date().toISOString().slice(0,10)` is the UTC day and is off by one near midnight. Build `YYYY-MM-DD` from `getFullYear()`/`getMonth()`/`getDate()` and compare `due_at` strings against that. ### Make it look designed (a quality floor, not a template) Aim for something that looks intentionally designed and tailored to THIS user — raise every app to this floor, then diverge in taste; do not ship bare, unstyled browser defaults. - A cohesive palette as CSS variables (background, surface, text, muted, one accent), a clear type scale with comfortable line-height, consistent spacing, and a single corner-radius / shadow set. - Design EVERY state, not just the happy list: the login/auth screen, loading, empty, and error / conflict feedback. - Interactive affordances — visible hover / focus / active states and light transitions. - Let the domain set the mood (calm for personal life, focused and dense for work / dev, warm for studying) so it feels built for this user, not generic. ### Make it feel instant (optimistic updates) Network round-trips are the main source of lag. Apply each change to your local state and UI FIRST, then call the API in the background — never block the UI on the network, and never re-fetch the whole list after a single write. - Keep the in-memory todo array as the single source of truth and render from it. After a successful write, update the affected item FROM THE WRITE'S RESPONSE BODY — a POST/PATCH returns the updated todo, including its new `version` — so you do NOT GET the whole list again just to "resync". Fetch the full list only on initial load or an explicit refresh. - Create: insert a temporary card the moment the user hits Enter and keep the input focused for the next entry; when the POST resolves, swap the temp item for the returned todo (real `id` + `version`). - Edits (status toggle, drag between boxes, custom-field change): mutate the local item and re-render immediately, then PATCH in the background. - Roll back on failure: if a write rejects, revert the local change and tell the user. On `VERSION_CONFLICT`, refetch just that one item (not the whole list), reconcile, and retry only if the intent is still clear. ### Delivering the app: tell the user to open it in a real browser The app calls `api.wspc.ai` from the browser (cross-origin fetch + an OAuth popup/redirect). Many in-chat / in-app preview panes — including the Claude app's built-in artifact viewer — run inside a restricted WebView that blocks external network requests (CSP), so the app will look broken there: login or loading hangs even though the code is correct. So when you hand over the file, EXPLICITLY tell the user to get it OUT of the inline preview and open it in a real web browser. Use whichever action your host shows on the generated file: - "Open in Chrome" / "Open in browser" (e.g. the Claude desktop app) — opens the file directly in a real browser. Point the user to this button. - "Download" (e.g. Claude web / claude.ai) — tell the user to click Download, then open the saved `.html` by double-clicking it. The app is fully self-contained, so `file://` works with no server or deploy. - If your host shows neither, have the user copy the code into a `.html` file and open that. Deploying to a static host (Cloudflare Pages / Vercel / GitHub Pages) also works and is best for repeated use, but is not required just to try it. Name the specific button you can see in your final message — do not assume the inline preview can run the app (it usually cannot, in both the Claude desktop app and Claude web). ### Todo schema reference (do not guess) `GET /todo/items?project_id=prj_...` returns `{ "todos": Todo[], "next_cursor"?: string }` (`project_id` query parameter is strictly required; omission returns 400). When `next_cursor` is present in the response, pass it as `cursor=` to fetch the next page. `next_cursor` is absent on the last page. Use `limit` (integer, max 200, default 50) to control page size — out-of-range values are clamped, never rejected. A single Todo: { "id": "tod_01HW3K4N9V5G6Z8C2Q7B1Y0M3F", "title": "Buy groceries", "description": "milk, eggs, bread", "status": "open", "due_at": "2026-05-20", "version": 1, "created_at": 1779070000000, "updated_at": 1779070000000, "type_id": "typ_01HW3K6V0Q3N8B9E2A1C4D7F5G", "custom_fields": { "priority": "high", "tags": ["urgent", "shopping"] } } Key points: - `status` is an enum: `"open" | "in_progress" | "done" | "cancelled"`. There is no boolean `completed` field. To mark a todo done, PATCH `{ "status": "done" }`; to reopen, PATCH `{ "status": "open" }`. - `due_at` is an ISO date string `YYYY-MM-DD` (no time component). To leave it unset, OMIT the key or send `""` — never send `null`. An empty date `` reads as `""`, so the reflexive `due_at: dateInput.value || null` is WRONG (see the null rule below). - **Optional core fields reject JSON `null`.** `description`, `status`, `due_at`, and `type_id` each want a real value, or `""` where clearing is allowed (`description`, `due_at`), or the key OMITTED — sending `null` fails with `VALIDATION_ERROR` ("expected string, received null"). Do not reflexively serialize `field: value || null`; use `value || undefined` (JSON drops undefined keys) or omit the key. The ONLY places `null` is a meaningful value are `parent_id` (`null` = root level, on both POST and PATCH) and, on PATCH only, `custom_fields[key]: null` (= delete that field value). - `version` increments on each write. Pass it back as `expected_version` for optimistic concurrency — in the JSON request body for BOTH PATCH and DELETE (not as a query param) — or omit to skip the check. - `created_at` / `updated_at` are Unix milliseconds. - POST /todo/items with { "title": "...", "project_id": "prj_..." } creates a todo (project_id required, other fields optional). PATCH /todo/items/{id} accepts any subset of mutable fields. DELETE /todo/items/{id} soft-deletes and takes an optional JSON body `{ expected_version?, cascade? }` — `cascade: true` soft-deletes the whole subtree; `expected_version` goes in this body, NOT as a query param. For full request schemas (validation, optional params like `parent_id` / `cascade`), fetch the OpenAPI URL above. ### Custom fields reference Every todo belongs to exactly one *type*. New users have a lazily-seeded `Default Project` and `Default` type. Types and todos are project-scoped, so resolve a `project_id` first with `GET /todo/projects` → `{ projects: [...] }` (no params required; pick the first project — there is no `is_default` flag). Use `/todo/types` to manage types and their custom field schema: - `GET /todo/types?project_id=prj_...` lists a project's types and returns `{ types: [...] }`. `project_id` is REQUIRED — omitting it returns 400 `VALIDATION_ERROR` (same rule as `/todo/items`). - `POST /todo/types { label, custom_fields?: [...], project_id: "prj_..." }` `project_id` is required to create the type in the specified project. Field types in v1: `string` | `string_array`. Field keys must match `^[a-z][a-z0-9_]{0,63}$` and cannot collide with reserved names (`title`, `status`, `id`, etc.). - `POST /todo/items` accepts `type_id?` (defaults to the user's default type) and `custom_fields?: { [key]: string | string[] }`. - `PATCH /todo/items/{id}` accepts `custom_fields` where `null` means "delete this key". - Filter: `?cf.priority=high&cf.tags=urgent` (AND, equality / array-contains). Each `` must be declared on an active type; an undeclared key returns 422 `UNKNOWN_CUSTOM_FIELD` (no silent empty result). A malformed `cf`-prefixed param that isn't dot syntax (e.g. `cf[key]=...`) returns 400 `VALIDATION_ERROR`. - Sort: `?sort_by=cf.priority&order=asc`. Only declared `string` keys are sortable. Unknown core keys, missing `cf.` prefix, undeclared `cf.*` keys, and `string_array` fields all return 422 `INVALID_SORT_KEY` (no silent fallback). - Orphan field values: when a type is soft-deleted or a field is removed from the schema, the underlying values are preserved but hidden from `GET` by default. Pass `?include_orphan_fields=true` to surface them. Restoring the type / re-adding the field instantly brings values back live. Type schema rules: - `key` and `type` are immutable on existing fields (rename = remove + re-add). - `title` cannot be hidden; `description`, `status`, `due_at`, `parent_id`, `recurrence` can be listed in `hide_core_fields` as UI hints (server still stores them). - `DELETE /todo/types/{id}` is soft delete; restore via `POST .../restore`. - The current default type cannot be soft-deleted; switch default first. ### Recurring todos (recurrence rules) A recurrence rule materializes todo instances on a repeating schedule from an RFC 5545 RRULE. The rule owns a hidden template todo; the server copies that template each time it materializes a new instance (up to ~14 days ahead). Editing or deleting a rule never retroactively rewrites already-materialized instances. As always, the live OpenAPI is authoritative. - `POST /todo/recurrence-rules { rrule, dtstart, title, description?, parent_id?, project_id, type_id? }` creates a rule. `rrule` is an RFC 5545 pattern (no `DTSTART` / `TZID`); `dtstart` is the anchor date as ISO date-only `YYYY-MM-DD`. `type_id` is OPTIONAL: omit it to adopt the project's default type; when set it must be an active type belonging to the rule's project. The type's custom-field DEFAULT values are copied onto the template and onto every materialized instance. A REQUIRED custom field with NO default makes rule creation FAIL with `MISSING_REQUIRED_FIELD` (caught once at create time, not per-materialization). Returns the rule plus `template_todo_id` and the initial `materialized_instance_count`. - `GET /todo/recurrence-rules?project_id=prj_...` lists active rules (newest first). Each item includes `type_id`, derived from the rule's template. - `GET /todo/recurrence-rules/{id}` returns the rule plus its template todo snapshot (which carries `type_id` and `custom_fields`) and the count of materialized instances — use it to inspect which type a rule uses before editing or deleting. - `DELETE /todo/recurrence-rules/{id}` stops future materialization and soft-deletes the template; already-materialized todo instances are KEPT. Takes an optional JSON body `{ expected_version? }` — pass it to fail with `VERSION_CONFLICT` if the rule changed since you last observed it, or omit to delete unconditionally. This is destructive; confirm with the user first. Subtasks on every occurrence: the rule's template todo can have children, and the server copies the whole template subtree into each materialized instance. To make a subtask repeat with the rule, create a todo whose `parent_id` is the template id (`template_todo_id` from create, or `template.id` from GET). Future unmodified occurrences re-materialize to include it; nesting is one level deep. ### SPA conventions (apply in all cases) This is a pure-frontend single-page app — all logic runs in the browser, no backend. The auth flow is the inline "OAuth 2.1 device flow" section above (do not re-derive it); beyond that, regardless of framework: - Cache `client_id` in localStorage (suggested key `wspc.client_id`): register once on first run, reuse on reload. - Cache `access_token` / `refresh_token` in localStorage, with an inline comment warning that localStorage is XSS-vulnerable. Refresh on 401 / expiry per the OAuth section above; on refresh failure, clear tokens and restart the flow. - Use `/todo/items` (list/create) and `/todo/items/{id}` (patch/delete) per the todo OpenAPI; respect the `status` enum (`open|in_progress|done|cancelled`, no boolean `completed`) and pass `expected_version` only when you want optimistic-lock conflict detection. ## Todo comments Comments are threaded notes attached to individual todos. Author is the authenticated user's `user_id`; there is no explicit author field to pass. Delete is soft — no restore path. Endpoints: POST /todo/items/{id}/comments create a comment; body: { "content": "..." } GET /todo/items/{id}/comments list comments on a todo PATCH /todo/comments/{id} update comment content; body: { "content": "..." } DELETE /todo/comments/{id} soft-delete a comment List defaults oldest-first. Supports `order=asc|desc` and `include_deleted=true`. Returns `{ "comments": Comment[], "next_cursor"?: string }`. Use `limit` (max 200, default 50) and `cursor` (from previous `next_cursor`) to page through results. Content maximum is 10 000 characters. No `expected_version` / optimistic lock. Deleted comments are hidden from the default list and cannot be restored.