# Kade Tasks API Contract — Phase v0.2

**Author:** Forge
**Consumer:** Riv (Slack bot for Kade)
**Date:** 2026-05-24
**Status:** Draft — Riv, ack this shape before writing bot handlers. If anything is wrong, flag now, not after the bot is built.

Companion files:
- v0.1 meds contract (reference): `Team Inbox/Atlas/kade-api-contract.md`
- Migration for new fields: `Team Inbox/Forge/continuity-system/migrations/008_kade_tasks_v02.sql` (drafted below in §Schema additions)
- Existing kade_tasks scaffold: `Team Inbox/Forge/continuity-system/migrations/006_kade_tables.sql`

---

## Base URL & auth

Same as v0.1: `http://localhost:3000/api/kade`, bearer token via `Authorization: Bearer $ATLAS_INGEST_TOKEN`, JSON in / JSON out.

---

## Schema additions (migration 008)

Two new columns on `kade_tasks` so we can quote Jimmie back to himself when his task is vague:

```sql
ALTER TABLE kade_tasks
  ADD COLUMN IF NOT EXISTS parsed_title    text,
  ADD COLUMN IF NOT EXISTS original_wording text;

-- Backfill: existing content becomes both fields
UPDATE kade_tasks
  SET parsed_title = COALESCE(parsed_title, content),
      original_wording = COALESCE(original_wording, content)
  WHERE parsed_title IS NULL OR original_wording IS NULL;

-- Going forward: content remains the canonical display field; bot fills parsed_title from LLM cleanup and original_wording from raw user message.
```

Why both `content` and `parsed_title`? `content` stays as the system-of-record display string (what shows in Top 3, what's quoted in DMs). `parsed_title` is the LLM-cleaned title (shorter, normalized). `original_wording` is the raw inbound — what Jimmie literally typed. The bot writes all three on create; reads can choose which to surface.

Server-side ranking weights live in code, not schema (see §Ranking).

---

## Endpoints

### `POST /api/kade/tasks`

Create a single task from a parsed DM. Bot writes this when it classifies an inbound message as "remind me about X" / "X by Friday" / "X is on me to do".

**Request body:**
```jsonc
{
  "content":          "send Acadian cleanup quote",       // required — the canonical display string
  "parsed_title":     "send Acadian cleanup quote",       // optional — LLM-normalized; defaults to content
  "original_wording": "yo remind me to send acadian quote thing",  // optional — raw DM; defaults to content
  "due_at":           "2026-05-27T17:00:00-05:00",        // optional, ISO 8601 with offset
  "project_id":       null,                                // optional, FK to projects(id) — bot auto-links via classifier if possible
  "client_id":        null,                                // optional, future FK
  "priority":         3,                                   // optional, 1=top..5=lowest, default 3
  "source":           "slack",                             // optional, default "slack" — one of: slack|voice_memo|manual|larry|mem.ai
  "external_id":      "1716470400.001234"                  // required — Slack message ts (or equivalent) for idempotency
}
```

**Response `201 Created`:**
```jsonc
{
  "ok": true,
  "task_id": 87,
  "project_id": 12,           // null if no auto-link
  "auto_linked_project": true // true = server attached project_id via classifier
}
```

**Response `200 OK` — idempotent re-post** (same `source` + `external_id`):
```jsonc
{
  "ok": true,
  "task_id": 87,
  "deduped": true
}
```

---

### `POST /api/kade/tasks/complete`

Batch-complete one or more tasks. Bot writes this on "X, Y, Z — done" / "X is done" / 👍 reactions on task DMs.

**Request body:**
```jsonc
{
  "task_ids":    [87, 88, 89],            // optional if external_ids provided
  "external_ids": [                       // optional if task_ids provided
    {"source": "slack", "external_id": "1716470400.001234"}
  ],
  "completed_at": "2026-05-24T14:22:00-05:00",  // optional, defaults to server now()
  "channel":      "slack",                       // optional, default "slack" — for audit
  "notes":        "shipped to client"           // optional, applied to all completed tasks
}
```

At least one of `task_ids` or `external_ids` is required. Both are unioned and deduped server-side.

**Response `200 OK`:**
```jsonc
{
  "ok": true,
  "completed": [87, 88, 89],          // task IDs that transitioned to done
  "already_done": [],                  // task IDs that were already done — no-op
  "not_found": [{"source":"slack","external_id":"missing"}]  // requested but not in DB
}
```

---

### `PATCH /api/kade/tasks/:id`

Single-task update — snooze, reschedule, kill, edit, change priority/project.

**Request body** (all fields optional, at least one required):
```jsonc
{
  "status":       "snoozed",                          // open|done|snoozed|killed
  "due_at":       "2026-05-30T09:00:00-05:00",        // null clears
  "snooze_until": "2026-05-26T08:00:00-05:00",        // null clears
  "priority":     1,
  "content":      "send Acadian cleanup quote — second nudge",
  "parsed_title": "send Acadian cleanup quote",
  "project_id":   12,                                  // null clears
  "notes":        "Jimmie bumped this Sat morning"     // appended to audit (not the content field)
}
```

**Response `200 OK`:**
```jsonc
{
  "ok": true,
  "task_id": 87,
  "task": { /* full task object — see GET shape below */ }
}
```

**Response `404 Not Found`** if no task with that id.

---

### `GET /api/kade/tasks/top?n=3`

Server-ranked top-N active tasks. Default `n=3`, max `n=10`. Powers "Top 3" voice.

**Query params:** `n` (1–10, default 3).

**Response `200 OK`:**
```jsonc
{
  "ok": true,
  "as_of": "2026-05-24T14:22:00-05:00",
  "tasks": [
    {
      "id": 87,
      "content": "send Acadian cleanup quote",
      "parsed_title": "send Acadian cleanup quote",
      "original_wording": "yo remind me to send acadian quote thing",
      "status": "open",
      "due_at": "2026-05-27T17:00:00-05:00",
      "snooze_until": null,
      "project_id": 12,
      "project_slug": "acadian-roofing-cleanup",
      "client_id": null,
      "priority": 1,
      "source": "slack",
      "external_id": "1716470400.001234",
      "created_at": "2026-05-21T09:15:00-05:00",
      "updated_at": "2026-05-24T08:00:00-05:00",
      "completed_at": null,
      "rank_score": 92.4,       // server-computed; debug-friendly
      "rank_reasons": ["overdue: 0d", "priority: 1", "project_active"]
    }
    // ... up to n
  ]
}
```

---

### `GET /api/kade/tasks/today?max=8`

Today's curated list. Default `max=8`. Excludes snoozed-until-after-today and done. Includes overdue (rolled forward) + tasks with `due_at::date = today` + the highest-ranked open tasks needed to fill up to `max`.

**Response shape:** same as `/tasks/top`, plus a top-level `composition`:

```jsonc
{
  "ok": true,
  "as_of": "2026-05-24T14:22:00-05:00",
  "max": 8,
  "composition": {
    "overdue": 2,
    "due_today": 3,
    "filler_top_ranked": 3
  },
  "tasks": [ /* ... */ ]
}
```

---

### `GET /api/kade/tasks/all`

"List From Hell" — every open + snoozed task, server-ranked. Capped at 200 for sanity; if more, paginate via `?after_id=N` (`tasks` returns sorted by `rank_score DESC, id ASC`, `next_after_id` in response if more).

**Response shape:**
```jsonc
{
  "ok": true,
  "as_of": "2026-05-24T14:22:00-05:00",
  "total_open": 47,
  "total_snoozed": 12,
  "tasks": [ /* same shape, sorted by rank */ ],
  "next_after_id": null   // or e.g. 142 if more pages exist
}
```

---

### `GET /api/kade/tasks/overdue`

Tasks where `status='open'` AND `due_at < now()`. Sorted by `due_at ASC` (oldest first).

**Response shape:** same as `/tasks/top` minus the rank fields; adds `days_overdue` per task.

---

### `GET /api/kade/tasks/upcoming?days=7`

Tasks where `status='open'` AND `due_at BETWEEN now() AND now()+interval 'N days'`. Default `days=7`, max `days=90`.

**Response shape:** same as `/tasks/overdue`; adds `days_until_due` per task.

---

### `GET /api/kade/tasks/:id`

Single task lookup. Returns the full task object as in `/tasks/top`.

---

## Ranking (server-side)

Single formula, evaluated at query time. Bot never re-ranks.

```
rank_score =
  + 100 * (1 if overdue else 0)                       // overdue dominates
  +  10 * days_overdue                                // older overdue ranks higher
  +  20 * (6 - priority)                              // priority 1→100, 5→20
  +  15 * (1 if due_today else 0)                     // due-today bonus
  +   5 * (1 if project_active else 0)                // attached to a non-blocked, recently-touched project
  -   5 * days_until_due (if due in future and not today)  // far-future drops rank
  -  50 * (1 if snoozed_until_in_future else 0)       // snoozed sinks
```

Rank is computed in SQL using these inputs:
- `kade_tasks.priority`, `due_at`, `snooze_until`, `status`
- `projects.status`, `projects.last_touched_at` (via JOIN where project_id IS NOT NULL)
- `now()` for relative math

`rank_reasons` is built server-side as a small array of strings for debugging — Riv can ignore but it's there.

**Tunability:** weights live in `lib/kade-tasks.ts` as a single config object. Change in one place; affects all list endpoints.

---

## Error responses

Same shapes as v0.1:
- `400` — validation: `{ ok:false, error, field? }`
- `401` — unauthorized
- `404` — not found (only on `:id` paths)
- `429` — rate-limited (defensive, unlikely)
- `500` — `{ ok:false, error:'internal', trace_id }`

Retry semantics: Riv's bot already does exponential backoff (1→2→4→8→16, max 30s, 5 attempts) on 5xx and connect errors. Same logic applies here.

---

## Idempotency contract

- **Create:** `(source, external_id)` uniquely identifies a task. Re-POSTing with the same pair returns the original task_id with `deduped: true`. Bot can safely retry on crashes mid-write.
- **Complete:** marking an already-done task is a no-op; the task appears in `already_done` not `completed`.
- **Patch:** non-idempotent by nature but safe to retry — the final state is what was sent.

---

## Time zones

All timestamps are ISO 8601 with explicit offset (`-05:00` CDT / `-06:00` CST). Server stores UTC internally; API translates on read. Riv: send everything with explicit offsets, same as meds contract.

---

## Project auto-linking

On `POST /api/kade/tasks`, if `project_id` is null, server runs the existing project classifier (`lib/project-classifier.ts`) against `content` + `original_wording` and attaches the best match if confidence exceeds threshold. Response includes `auto_linked_project: true|false` so the bot knows whether to surface "I tagged this to project X" in the DM ack.

If the classifier isn't reusable as-is (it was built for documents), flag back and we'll either generalize it or skip the auto-link for v0.2.

---

## What's NOT in v0.2

- **Reminders** — `kade_reminders` table exists; endpoints land in v0.3 alongside DND and kick-in-the-pants
- **Lists (kade_lists / kade_list_items)** — the Top 3 / Today / List From Hell views are computed live in v0.2, not persisted. Persisted custom lists land in v0.5
- **`/kade` web UI** — separate phase
- **Habits (kade_habits)** — v0.3 with the scheduler

If Riv hits a scenario that needs one of the above, flag it before expanding the contract — schema drift kills these projects.

---

## Sign-off

Riv: reply in Team Inbox or DM Larry with one of:
- ✅ Contract acked, will build against this
- 🔧 Change request: [what + why]
- ❓ Question: [what]

Endpoints + migration 008 will not ship until Riv acks. No point shipping code whose shape is wrong.

— Forge
