docs(spec): Phase 8 — agent API

Design for a bearer-token-authenticated REST API letting an external
AI coding agent read every project/card the owner has and write back
exactly three things: a comment, subtasks, or a column move. Follows
the same auth pattern as the existing CRON_SECRET-protected route.
Agent-authored comments are prefixed so they're never mistaken for
something the owner typed, mirroring Phase 7's "Logged" tag principle.
MCP explicitly deferred to a later, thinner follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-25 22:14:24 +02:00
parent 3b869d6e48
commit 1c57d2290d

View file

@ -0,0 +1,175 @@
# Phase 8: Agent API — Design
## Problem
The owner wants to point an external AI coding agent (Claude Code, MiniMax,
or whichever tool is in front of them at the time) at their own backlog —
read what's in the Inbox and on the board, draft an implementation plan for
a card, and write that plan back onto the card — without that agent having
a browser session or a PocketBase login of its own.
## Goals
- A machine-to-machine API an external agent can call with a single static
credential, no session, no login flow.
- Read access across every client/project the owner has — the agent needs
to see what's already in progress, not just one Inbox in isolation.
- Write access limited to exactly three actions: comment on a card, add
subtasks to a card, move a card to a different column.
- An agent-authored comment must be honestly marked as agent-drafted,
never indistinguishable from something the owner typed — the same
principle Phase 7's "Logged" tag exists for, applied here.
- A plain REST API, not MCP, for now — the owner hasn't settled on which
agent tool will be the primary consumer, and a REST API is callable by
anything with a fetch/curl-capable tool. MCP can be layered on top later
as a thin wrapper without redesigning anything here.
## Non-goals
- No per-client or per-project scoping of what the agent can see. This
surface exists for the owner's own tooling; it is inherently
full-visibility, the same way the admin pages are.
- No OAuth, no multiple API credentials, no per-agent permissions. One
token, one trust level — full owner-equivalent access — matching this
app's existing single-owner architecture (the same posture as the
PocketBase superuser credentials it already holds).
- No MCP server in this phase. Explicitly deferred.
- No card creation, deletion, or field edits beyond the three write actions
listed above. An agent that wants to change a card's title, priority, or
description does so by leaving a comment for the owner to act on, not by
editing the card directly.
## Authentication
A single bearer token, `AGENT_API_TOKEN`, generated once (e.g. `openssl rand
-hex 32`) and stored in `.env.local`/Coolify — never in a client bundle,
never logged. Checked on every request:
```
Authorization: Bearer <AGENT_API_TOKEN>
```
This is the exact pattern already used by `/api/cron/reconcile` (`CRON_SECRET`)
— nothing new is being introduced to this codebase's security model, just a
second secret of the same kind for a second machine caller. A request with
no token, a wrong token, or the token unset in the environment gets a 401
without touching PocketBase.
This is a full owner-equivalent credential. Anyone holding it can read every
client's board and post/move/comment as the owner. It must be treated with
the same care as the PocketBase superuser credentials.
## Routes
All under `/api/agent/`, all reading and writing through the superuser
PocketBase connection (`getAdminPb()`), since this surface has no per-caller
identity to scope by — the token itself is the identity, and that identity
is "the owner."
### `GET /api/agent/projects`
Every project across every client, regardless of status filtering used
elsewhere in the app (the agent should be able to see paused/completed
projects too, not just active ones — it is planning, not browsing).
```json
[
{
"id": "...", "name": "WFDash Web App", "slug": "wfdash-webapp",
"client": { "id": "...", "name": "WF", "slug": "wf" },
"kind": "project", "status": "active"
}
]
```
### `GET /api/agent/projects/:projectId/cards`
Every card in the project, any column, sorted by column position then card
position — the same order the board itself renders in. An optional
`?column=inbox` query parameter filters to the Inbox column only (matching
`is_inbox`, not a literal column name, since column names are editable).
```json
[
{
"id": "...", "number": 42, "title": "...", "description": "...",
"priority": "medium", "target_date": "", "size_band": "medium",
"column": { "id": "...", "name": "Inbox", "is_inbox": true, "is_done": false }
}
]
```
### `GET /api/agent/cards/:cardId`
Everything above for one card, plus:
- `subtasks`: existing checklist items (`title`, `done`)
- `comments`: existing comments (`author` name, `body`, `created`)
- `recentCommits`: the last 10 linked commits' summaries (or raw subject if
unsummarised) and dates — enough for the agent to know what work has
already touched this card without pulling full commit history
### `POST /api/agent/cards/:cardId/comments`
Body: `{ "body": string }`.
The stored comment is prefixed before being written, so no caller can
accidentally or deliberately post something indistinguishable from the
owner's own words:
```
🤖 Drafted by your agent:
<body>
```
`author` is resolved server-side to the app's own owner user record (looked
up by `role = "owner"` — there is exactly one, by this app's design) — never
taken from the request. The comment renders through the exact same
`comments` list every card already has; no new UI work is needed.
### `POST /api/agent/cards/:cardId/subtasks`
Body: `{ "titles": string[] }`. Creates one subtask per title, appended
after the card's existing subtasks in position order (same fractional
spacing every other subtask-creation path in this app uses). Each title is
trimmed and capped at the schema's 200-character limit; an empty array or
an array of only-empty strings is rejected with 400 rather than silently
creating nothing.
### `PATCH /api/agent/cards/:cardId/move`
Body: `{ "column": string }` — a column **name** (e.g. `"Backlog"`), not an
id, since the agent has no reason to know internal PocketBase ids. Resolved
case-insensitively against the card's own project's columns; an unknown
name is rejected with 400 listing the project's actual column names, so a
caller can self-correct without a separate lookup call.
The card is appended to the end of the target column (highest existing
position in that column, plus the standard spacing constant) — there is no
concept of "drop position" for a machine caller, so it always goes last,
exactly like a freshly created card does.
## Error handling
Every route returns the existing `jsonError(message, status)` /
`jsonOk(data)` shape already used by `/api/cron/reconcile` and the webhook
routes — no new response envelope invented for this surface. 401 for a
missing/wrong token, 404 for an unknown project/card id, 400 for a
malformed body or an unresolvable column name.
## Testing
Integration tests hit these routes directly with `fetch`, the same pattern
used throughout `tests/integration/` — a request with no token or the wrong
token must be refused before touching PocketBase; a request with the right
token must perform exactly the described read/write and nothing else; the
comment-prefixing and column-name resolution get their own specific
assertions since they are the two places a subtle bug would be easy to miss
(a missing prefix silently defeats the whole "honestly marked" requirement).
## Out of scope for this spec
- An MCP server wrapping these routes — a natural, small follow-up once a
primary agent tool is settled on, not part of this phase.
- Any UI in the app itself for managing or rotating `AGENT_API_TOKEN` — it
is set the same way `CRON_SECRET` already is, by hand, in the environment.