# ogma projects — LLM-ready reference

> ogma projects is a kanban board app (projects → columns → cards) in the
> ogma workspace suite. This page is the machine-oriented reference for its
> JSON API and core concepts. Everything here is served by the same host this
> file came from; all paths are relative to it. The human guide lives at
> `/help`. MCP clients: the workspace portal host serves this same API as
> `projects_*` tools at `/mcp` — see the portal's /llms.txt. That host also
> serves the `ogma` skill (the workspace's working conventions) as
> `skill://ogma/SKILL.md` over MCP, or `/skills/ogma/SKILL.md` over plain
> HTTP — alongside `ogma-fleet` (working a board's queue as a bot) and
> `ogma-acceptance` (reviewing a card someone has called finished).

## Concepts

- A **project** is a board: ordered **columns** holding ordered **cards**.
  New projects start with the columns Backlog · Triage · Ready · In Progress ·
  Testing / Acceptance · Done.
- **Cards** carry: title, Markdown body, one optional **assignee** (a project
  member — on an agent card, its **lead**: the person who answers for it,
  required), a **worker** on agent cards (the agent claiming it), any number
  of **labels**, one optional **priority**, an optional
  **due date**, comments, attachments, and a checklist.
- **Labels** are a per-project vocabulary of (name, colour). Colours are one
  of: gray, red, orange, amber, green, teal, cyan, blue, indigo, violet,
  purple, pink. New projects seed: bug (red), chore (gray), docs (blue),
  enhancement (cyan), new feature (green), performance (indigo), refactor
  (purple), security (orange).
- **Priorities** are a per-project ORDERED vocabulary (name, colour,
  position; ascending position = most urgent first). New projects seed:
  urgent (red), high (orange), medium (amber), low (gray). Manage the
  ladder in board settings or via the priorities API below.
- **Roles** per project: viewer (read-only) < member (edit cards/columns/
  labels/priorities) < admin (settings, members) < owner (delete project,
  transfer ownership). A token acts as its user and carries that user's
  roles.
- **Member labels**: a per-board vocabulary of participant TITLES
  (architect, developer, ...), for people and bots alike. Curated in board
  settings (admin); admins assign, members may drop their own (admins are
  notified). A title carries no capability: it is not a role (which decides
  permissions) and not a pool worker's role (implement/review/explore, a
  closed platform set naming an agent profile). Deleting a held label is
  refused; renaming is free.

## Authentication

Send an API token as a bearer token on every request:

    Authorization: Bearer gbn_…

Tokens are created by the user in the account service (portal → Profile →
API tokens) and are scoped: `projects:read` allows GET, `projects:write` is
required for anything mutating. Mutating JSON endpoints require
`Content-Type: application/json`. Without a valid token every endpoint
returns 401 `{"error":{"code":"unauthenticated", …}}`.

## Endpoints (`/api/v1`)

Ids are UUIDs. Timestamps are RFC 3339 strings. Optional fields are null
when absent.

### Identity
- `GET /api/v1/me` → `{id, email, display_name, system_role}`

### Projects
- `GET /api/v1/projects[?include_archived=true]` → array of projects visible
  to you. Archived boards are left out unless you ask for them — which is how
  you find one again to restore it.
- `POST /api/v1/projects` `{name, description?, repo_url?}` → 201 project.
  Creates the six default columns, eight default labels, the four
  default priorities, and four default member labels (titles only).
- `GET /api/v1/projects/{id}` → project.
- `PATCH /api/v1/projects/{id}`
  `{name?, description?, repo_url?, agent_wip_limit?}` → project.
- `POST /api/v1/projects/{id}/archive` → project. Soft-archive: the board
  leaves the default listing and goes read-only, but nothing is destroyed —
  every column, card, comment and attachment survives. Owner or admin.
  Idempotent.
- `POST /api/v1/projects/{id}/unarchive` → project. The mirror: puts an
  archived board back, editable again. Same permission, also idempotent.
- `DELETE /api/v1/projects/{id}` → 204 (owner only; irreversible). Prefer
  archive — this one does not come back.

Project shape: `{id, name, description, repo_url, repo_provider,
agent_wip_limit, created_by, archived_at, created_at, updated_at}`.
`archived_at` is null while the board is in use, and stamped once archived. `agent_wip_limit` caps
how many unfinished cards one agent identity may hold here (see Claiming);
null = unlimited.

### Columns
- `GET /api/v1/projects/{id}/columns` → array of
  `{id, project_id, name, position, counts_as_done, gated, worker_role,
  persona_id, specialists_only, intake, cards: [card…]}`
  — the whole board in one call; cards come in board order. `counts_as_done`
  marks a column whose cards are finished work; `gated` means entry is
  refused until the card's gate criteria are acknowledged (see Status
  gates). Colour, WIP limit and hidden stay out of the API.
- `POST /api/v1/projects/{id}/columns` `{name}` → 201 column (appended).
- `PATCH /api/v1/columns/{id}` `{name?, position?, counts_as_done?, gated?, intake?}` →
  column. `position` is the 0-based index to move the column to; `gated` makes
  entry conditional on the column's gate criteria (see Status gates). `intake`
  marks where the board's agent work starts (card #363): `claim_next` never
  offers a card from a column to its left, so Backlog and Triage stay the
  people's. One per board — setting it clears the previous one; a new board's
  is Ready; a board with none offers every column. Refused on a column that
  counts as done. A refused PATCH changes nothing.
  It also takes `persona_id` — the **hat** this column's cards are worked
  in (card #379; one of the board's hats, `""` clears) — and
  `specialists_only` (only agents that have this hat may take its cards; a
  general purpose agent never is). `worker_role` (`implement` / `review` /
  `explore`) is the older spelling: it sets the built-in hat of that role,
  and reads back as the column's hat's role. A move into a column with
  another hat is the hand-over: whoever wears that hat is offered the card.
  Refused together with `counts_as_done`.
- `DELETE /api/v1/columns/{id}` → 204. Deletes the cards in it.

### Hats
A board's **hats** (card #379) are the ways its agent work is done: a
`name`, a `role` — `implement` (may change things), `review` (reads and
judges; runs and changes nothing) or `explore` (reads and tries; changes
nothing) — and the `instructions` whoever wears it is given. Every board has
the built-ins Implement, Review and Explore; a board may add its own. A
column names the hat its cards are worked in; a pool agent may be given hats
of its own (its affinity — none = general purpose, may wear any). The board
picks who takes a ready card: a model the rater prefers first (when the
board has one), then a specialist before a generalist, then whoever has
waited longest.

- `GET /api/v1/projects/{id}/personas` → array of `{id, project_id, name,
  role, instructions, built_in, position, …}` in board order.
- `POST /api/v1/projects/{id}/personas` `{name, role, instructions?}` → 201
  hat. Needs ManageColumns, like the edits below.
- `PATCH /api/v1/personas/{id}` `{name?, role?, instructions?}` → hat. A
  built-in keeps its role.
- `DELETE /api/v1/personas/{id}` → 204. A built-in can't be deleted; the
  columns that wore the hat wear none.

### Cards
- `POST /api/v1/columns/{id}/cards`
  `{title, body?, assignee_id?, priority_id?, owner_type?}` → 201 card
  (appended at the bottom of that column). `assignee_id` must be an active
  project member and `priority_id` one of this project's priorities; absent
  or `""` leaves them unset. `owner_type` defaults to `"human"`. On an
  `"agent"` card `assignee_id` is the card's lead and must be a person (a bot
  is a 422); absent, the lead defaults to you — or, when you are a bot, to the
  lead of the card you split it from, else the board's owner.
- `GET /api/v1/cards/{id}` → card.
- `PATCH /api/v1/cards/{id}`
  `{title?, body?, assignee_id?, due_at?, priority_id?, owner_type?,
  shepherds?, shepherd_id?, work_phase?}` → card.
  Omitted fields keep their value. `assignee_id`, `due_at`, `priority_id`
  and `work_phase` clear with `""`. `shepherds` is the card's WHOLE watcher
  list — each entry a project member's user id or a member label's id
  ("the architects watch this"); `[]` clears; `shepherd_id` is its
  deprecated scalar alias ("" clears, an id replaces the list with that
  member; ignored when `shepherds` is present). `assignee_id`
  must be a project member — on an agent card a person, and it cannot be
  cleared (it is the lead; take the card off its agent with
  `DELETE /claim` instead); `due_at` is `YYYY-MM-DD`;
  `priority_id` must come from this project's priority ladder; `owner_type`
  is `"human"` or `"agent"`; `work_phase` is one of `analyzing`, `planning`,
  `implementing`, `recording`, `waiting`, `blocked` and may only be set by
  the card's current holder — its worker (or someone with EditProject).
  **`body` is written unconditionally**: the web editor compare-and-swaps on a
  revision so two people cannot silently overwrite each other, but a token
  caller has no conflict to resolve into, so a `body` here wins over anything
  a person currently has open and unsaved. Read the card first, and prefer
  appending a comment to rewriting someone's description.
- `POST /api/v1/cards/{id}/move` `{to_column_id, position}` → card.
  `position` is the 0-based index within the destination column.
- `POST /api/v1/cards/{id}/archive` → card. Soft-archive: the card leaves the
  board, every card listing and search, but nothing is destroyed — its
  comments, attachments and checklist all survive, and it still answers by id,
  which is how you find it again. Checklist items on OTHER cards that track
  this one go withdrawn, so their tallies drop it. Same permission as editing
  the card. Idempotent.
- `POST /api/v1/cards/{id}/unarchive` → card. The mirror: puts the card back on
  its board and back into the tallies. Same permission, also idempotent.
- `DELETE /api/v1/cards/{id}` → 204 (also removes its comments/attachments).
  Prefer archive — this one does not come back.
- `GET /api/v1/cards/{id}/activity[?limit=&before=]` → `{card_id, events:
  [{id, kind, text, actor_id, actor_name, target, detail, at}], next_before}`.
  The card's history, newest first. `text` is the ready-made sentence the card
  dialog shows — same renderer, so the two never drift — and the rest is the
  structure behind it, so a caller need not parse English back into facts.
  `target` is the column or member the event points at, by its CURRENT name:
  ids are resolved at read time, so a rename shows through the whole trail.
  `limit` defaults to 100 (cap 500); page by passing `next_before` back as
  `before` until it is absent, which means you have reached the card's
  creation. A cursor we did not issue is a 422 rather than a silent restart.

  **The trail records that something changed and what it changed TO, never
  what it changed FROM** — so it can be read forwards but not replayed
  backwards. "Who moved this and when" it answers exactly; "what was the
  assignee before" it cannot.

Card shape: `{id, number, project_id, column_id, title, body, position,
created_by, assignee_id, worker_id, priority_id, due_at, owner_type, shepherd_id,
shepherds, work_phase, attention, created_at, updated_at, archived_at,
checklist_done, checklist_total, kind, refs_total, refs_open, items_total,
items_open, labels}`.
`owner_type` is `"human"` or `"agent"` — whose queue the card is in. It is the
rail automation reads before treating a card as its own work (claims,
schedulers, auto-pickup all refuse `"human"`), and deliberately not a
permission: anyone may still read, comment on, or finish any card by hand.
`assignee_id` on an agent card is its **lead**: the person who answers for
it, always set and never a bot. `worker_id` is the agent working it right now
— the claim — and null when nobody holds it; only agent cards have one.
`shepherds` are extra watchers beside the lead —
`[{user_id?, member_label_id?, name}]`, each
entry a person or a member label whose holders all count. Like `attention`
it is filled on single-card responses (GET/PATCH) and empty on list
payloads; `shepherd_id` mirrors the first person-shaped entry there
(deprecated, one release). `work_phase` is the assignment stage while
claimed (null when unclaimed); the card face shows it as a pill. `attention`
is who is looking at the card right now, `[{actor_id, actor_name,
actor_is_bot, card_id, card_number, card_title, verb, summary}]` — present
on `GET /cards/{id}` only and omitted from list payloads (see Attention).
Single-card responses (GET, claim, `claim_next`) also carry:
`hat` `{id, name, role, instructions}` — the hat the card is worked in, its
column's (absent on a board without hats); `story` `{purpose, progress,
next, updated_by, updated_at}` once someone has written one; `room_id` on a
claim — the card's chat room, which the claimant has just joined; and
`guide` (card #385) — how the caller should work the card: `{you_are,
hat?, story: {purpose, progress, next, written}, needed_now: [{kind,
message_id, from, text}], room_id?, rules: [string]}`. `you_are` is
`worker`, `lead`, `watcher` or `none`; `needed_now` lists the room's open
instructions and unanswered questions; `rules` are plain sentences, in the
order they matter, naming the MCP tools to use. An LLM working a card
should read the guide first and do what it says.
`archived_at` is stamped when the card is archived and null while it is on
the board; archived cards are off every board and out of search but still
answer by id. `body` is Markdown;
`checklist_done`/`checklist_total` tally the card's checklist (0/0 = none).
`kind` is what that checklist makes of the card — `"leaf"` (no reference
pills: work of its own, or no checklist), `"container"` (pills only: its
work is other cards' work) or `"hybrid"` (both) — with the tally split by
what the items are: `refs_*` count pills tracking other cards, `items_*`
the card's own. All derived from the checklist (the `card_shape` view),
never set; an "epic" is a shape, not a type. A card whose only open items
are refs is waiting on other cards, not ready. Items are listed refs first,
then text, on every surface.
`labels` embeds the card's applied labels as `[{id, name, color}]` — manage
the project's set via the Labels endpoints below, attach/detach via
`/cards/{id}/labels`.
`number` is the card's per-board handle — incremental, unique within the
project, never reused, and what the UI displays (`#12`) and people say;
routes still address cards by `id`, so resolve a number via the board call.

### Claiming
- `POST /api/v1/cards/{id}/claim` → card. One atomic compare-and-swap
  (no worker → you, in `worker_id`; the lead in `assignee_id` is untouched),
  so two callers racing produce exactly one winner.
  **409** when someone else holds it (the message names them), when the card is
  `owner_type: "human"` (automation does not take human-owned work), when it is
  archived, or when you already hold the board's `agent_wip_limit` unfinished
  agent cards. Re-claiming a card you already hold is a success, so retries are
  safe.
- `DELETE /api/v1/cards/{id}/claim` → card. Releases the claim (idempotent).
  Anyone with EditCards may free a card whose holder has gone quiet; the trail
  records who did.
- `POST /api/v1/projects/{id}/claim-next` `{column_id?, label_ids?}` →
  `{card, message}`. Finds and claims (the same compare-and-swap) the next
  unclaimed `owner_type: "agent"` card on the board — `column_id` narrows it
  to one column, `label_ids` to cards carrying at least one of those labels.
  WIP-aware: `card` is null, with `message` saying why, when you already
  hold the board's `agent_wip_limit` or nothing is available. Prefer this
  when idle over scanning the board and racing claims by hand.
  The order is the board's **ready queue** (the `ready_cards` view, derived
  on every read, nothing stored). It starts at the board's intake column:
  nothing to its left is offered. Then priority first (no priority sorts last,
  not out), then a leaf of an epic already under way — its parent out of
  the first column, or with a landed child — before a leaf of an untouched
  one, then card number. A card whose only open checklist items are
  reference pills is **passed over**: its remaining work is other cards'
  work, so it is waiting, not ready. A decomposed parent is therefore never handed out ahead of its
  children; it comes back once every ref has ticked, for the closing work.
  A hybrid with an open item of its own is never held back by its refs.
  (A ref ticks when its card reaches a `counts_as_done` column — so a board
  that flags its acceptance column that way will offer the parent while the
  children still await a person's yes; flag only the final column if the
  parent should wait for sign-off.)

- `GET /internal/v1/ready?project_id=…&worker_id=…` (service-to-service,
  internal token) → `{count, cards: [{id, number, title, rank}]}` — the same
  queue, in order; with `worker_id`, only cards that pool worker's claim
  labels admit. The coordinator asks this before waking an idle worker.
- Every ready card's board face carries `data-ready-rank`, which is what the
  board's "Ready" chip counts.

Every successful claim by someone other than the last holder is a
**takeover** (card #296): a `card_handovers` row is written with who it came
from, who took it, that person's pool-worker role, the column and phase, and
a JSON snapshot of the card's scalar state at that moment — the point a
rollback (card #297) returns to. The card's activity trail gets a
`taken_over` line, and chat gets a notice in the new holder's agent channel
(and the previous holder's, when it has one): "<name> took over #12 “title”
at <column> as <role>, from <name>." A re-claim by the same holder, and a
release, are not takeovers.

`agent_wip_limit` is a board setting (`PATCH /projects/{id}`, default 1, 0 or
less = unlimited). It counts the claimant's cards that are `owner_type: agent`,
unarchived, and in a column that is not `counts_as_done` — a claimed card
parked in Backlog still counts, because it is yours and it is not done.

There is no auto-release and no auto-complete-on-idle. **Idle is not done.**

### Plans, decomposition, handoff
Durable ways to leave work on the card rather than in a session.

- `POST /api/v1/cards/{id}/plan` `{plan, as_checklist?}` → 201
  `{comment_id, checklist_added, card}`. Posts the plan (Markdown, 1–20000
  chars; a `## Plan` heading is prepended when missing, and the comment is
  cut at 4000 chars) as a comment on the card. With `as_checklist: true`,
  its list lines (`- `, `- [ ]`, `- [x]`, `1. `) also become checklist
  items, up to 50. Prefer this to a session-local todo list — it survives
  handoff.
- `POST /api/v1/cards/{id}/decompose` `{items: [{title, description?}],
  column_id?}` → 201 `{children: [card…], parent}`. Creates 1–40 child
  cards (titles ≤ 200 chars) as `owner_type: "agent"`, in `column_id`
  (default: the parent's column), and adds a checklist item to the parent
  tracking each child (`ref_card_id`), so the parent's tally follows the
  children's columns. Children are claimable the moment they exist.
- `POST /api/v1/cards/{id}/handoff` `{summary, to_note?}` → card. Posts a
  `## Handoff` comment (summary 1–3500 chars, `to_note` appended) and
  releases the claim so another agent can claim it — it does not reassign.
  **409** unless the caller currently holds the card.
- `POST /api/v1/cards/{id}/rollback` `{handover_id?, force?}` →
  `{handover_id, handover_at, taken_over_by, taken_over_by_name, restored,
  retracted_comments, overwritten, worktrees, notes, card}`. Puts the card
  back to the state the takeover recorded (the latest, or `handover_id`):
  title, body, column, priority, due, labels, checklist and shepherds
  restored; the claim and phase cleared; every comment and finding posted
  since (except the caller's own) stamped `retracted_at` — kept, shown
  muted, never deleted; the card's `agent/card-N` branch reset by the
  coordinator to where it stood when that holder started (`worktrees`
  lists what was reset; `null` when no coordinator is configured, and
  `notes` says if it could not). A `## Rollback` comment records it, the
  trail gets `rolled_back`, and the holders since are told in chat.
  **409** while the card is held, and while a non-bot member other than
  the caller has edited it since — unless `force`, in which case
  `overwritten` names them. **422** when the card never changed hands.
  Cross-card state is not touched: a pill elsewhere that tracks this card
  recomputes from the column it lands in.

### Stale work
Two detectors, both **read-only**. They badge cards and post a daily chat
digest; they never move, reassign or close anything. A detector that "fixed"
what it found would be indistinguishable from a bug and would destroy the trail
that explains the mess.

- `rot` — a claimed, unfinished card with no server-verified evidence for
  `rot_days` (default 3).
- `silence` — a claimed card its holder has not touched for `silence_hours`
  (default 24). The no-executor tripwire, in terms the board can verify.

**Only server-verified evidence clears a finding:** a `commit` or
`pull_request` activity entry (written by the repo hub when a push actually
arrived) or a gate acknowledgement whose `evidence` the server stored. A comment
saying "shipped, see abc1234" is a claim, not a fact, and does not count —
otherwise the detector is defeated by typing.

- `GET /api/v1/projects/{id}/rot` → `{project_id, enabled, rot_days,
  silence_hours, findings: [{card_id, card_number, card_title, column_name,
  assignee_id, signal, why}]}`. Any member may ask, whatever the board's
  background setting — asking is its own consent. `why` names the missing
  evidence in words.

Configure with `PATCH /projects/{id}` `{rot_detection?, rot_days?,
silence_hours?, rot_digest_channel_id?}`. Detection is **off** by default.
`rot_digest_channel_id: ""` keeps badges and stops the digest.

### Status gates
A column with `gated: true` refuses entry until every criterion resolved for
the card being moved has been explicitly acknowledged on it. A blocked
`POST /cards/{id}/move` returns **409** with
`{error: {code: "gate_blocked", message, gate: {scope, to_column, criteria,
how_to_ack}}}`. There is no override flag: acknowledge the criteria.

Criteria resolve in layers — the card's own, else the destination column's,
else the board default (`column_id` and `card_id` both null). The first
non-empty layer wins outright; layers never merge, so a card-level gate
*replaces* the column's rather than adding to it.

- `GET /api/v1/cards/{id}/gate` → `{card_id, gates: [{column_id, column_name,
  scope, satisfied, criteria: [{criterion_id, text, needs_evidence, acked,
  acked_by, evidence, acked_at}]}]}` — one entry per gated column on the
  board. Empty `gates` means nothing gates this card anywhere.
- `POST /api/v1/cards/{id}/gate/acks` `{criterion_id, evidence?}` → the
  refreshed gate state. One criterion per call. `evidence` is required when
  the criterion sets `needs_evidence` and is capped at 500 characters; the
  acknowledger is the authenticated caller, never a value from the body.
- `DELETE /api/v1/cards/{id}/gate/acks/{criterion_id}` → 204 (idempotent).
- `GET /api/v1/projects/{id}/gate-criteria` → array of
  `{id, project_id, column_id, card_id, text, needs_evidence, position}`.
- `POST /api/v1/projects/{id}/gate-criteria`
  `{text, column_id?, card_id?, needs_evidence?}` → 201 criterion.
  `column_id` and `card_id` are mutually exclusive; both absent = the board
  default. Needs ManageColumns.
- `PATCH /api/v1/gate-criteria/{id}` `{text?, needs_evidence?}` → criterion.
  Scope is fixed at creation.
- `DELETE /api/v1/gate-criteria/{id}` → 204 (its acknowledgements go too).

Acknowledgements are retired when a card lands in an **ungated** column: they
described the work as it stood, and a card going back into flight must be
vouched for again. Moves between two gated columns keep them.

### Attention (live presence)
Who is looking at which card right now — the ringed avatars on card faces and
the agent strip above the board. One locus per actor: stamping a card leaves
the previous one. Entries expire 45 s after their last stamp, so re-stamp
while you work.

- `POST /api/v1/cards/{id}/attention` `{verb, summary?}` → `{actor_id,
  actor_name, actor_is_bot, card_id, card_number, card_title, verb,
  summary}`. `verb` is `inspecting`, `reading`, `searching` or `working`;
  `summary` (free text) defaults to `reading #N`. A bot fetching a card via
  `GET /cards/{id}` is stamped `inspecting` automatically.
- `DELETE /api/v1/attention` → 204. Clears the caller's locus (idempotent).
- `GET /api/v1/projects/{id}/attention` → array of the entries above for
  every card on the board.

### Findings
Short, durable notes on a card — what was learned, as opposed to the
discussion in comments. They render in their own **Findings** section of the
card dialog and are stored apart from comments.

- `GET /api/v1/cards/{id}/findings` → array of `{id, card_id, author_id,
  author_name, body, finding_kind, created_at}`, newest first.
- `POST /api/v1/cards/{id}/findings` `{body, finding_kind}` → 201 finding.
  `body` is plain text, 1–280 chars; `finding_kind` is `observation`,
  `decision`, `blocker` or `result`. Needs EditCards.

### The Story
Three plain lines at the top of a card (card #383), for someone who is not
technical: `purpose` (what it is for), `progress` (what has happened),
`next` (what is needed now). The agent working a card keeps it current;
anyone on the board may correct it.

- `PATCH /api/v1/cards/{id}/story` `{purpose?, progress?, next?}` → the
  Story. PATCH semantics: an absent field keeps its value, `""` clears it.
  Each line is at most 600 characters. Needs EditCards.

### Comments — the card's room
Every card has a **room** (card #380/#381): its own chat channel in the
chat app, where the people on the board and the agent working the card
talk. Comments are messages in it — one timeline. A person's message has an
**intent**: `note` (information), `instruction` (do this — open until marked
done; an open instruction holds the card where it is: moving it is refused
until it is done) or `answer` (to an agent's question). An agent asks with
a chat message of kind `question`; the card shows **Needs you** and its lead
and shepherds are rung.

- `GET /api/v1/cards/{id}/comments` → array of
  `{id, card_id, author_id, author_name, body, created_at, edited_at,
  retracted_at, source, kind, intent, answers_id, done_at, author_is_bot}`
  (oldest first). `source` is `chat` (a room message) or `comment` (one of
  the card's older comments, kept in the same timeline). `kind` is the chat
  kind (`text`, `question`, `tool`, `notice`, `error`, `aside`). `intent`
  is what a person meant; null for an agent's words. `done_at` stamps a
  finished instruction. `edited_at` is null until the author edits;
  `retracted_at` is set by a rollback — the comment belongs to work that was
  undone, so read it as history, not as the current plan.
- `POST /api/v1/cards/{id}/comments` `{body, intent?, answers_id?}` → 201
  comment, posted into the card's room. `body` is Markdown. Without an
  `intent`, a reply while the agent is waiting on a question is its answer,
  and anything else a note. `answers_id` names the question an answer
  answers (absent: the open one).
- `POST /api/v1/cards/{id}/messages/{message_id}/done` `{done?}` → 204.
  Marks an instruction done (`done: false` opens it again).
- `PATCH /api/v1/comments/{id}` `{body}` → 200 comment — author-only, even
  for admins (they may delete a comment, never rewrite it). Stamps
  `edited_at`, shown as "(edited)" in the app.
- `DELETE /api/v1/comments/{id}` → 204 (own comments, or admin).

### Checklist
- `GET /api/v1/cards/{id}/checklist` → array of
  `{id, card_id, text, done, position, ref_card_id}` (list order: items
  tracking a card first, then free text, each group by `position`).
  `ref_card_id` is set when the item tracks another card on the board; its
  `done` then follows that card's column (`counts_as_done`) and is
  read-only.
- `POST /api/v1/cards/{id}/checklist` `{text}` or `{ref_card_id}` → 201
  item (appended, unticked). `text` is plain text, 1–500 characters.
  `ref_card_id` tracks a card on the same board (not the card itself);
  `text` is ignored when it is set.
- `PATCH /api/v1/checklist/{id}` `{text?, done?, position?}` → item.
  Omitted fields keep their value; `position` is the new 0-based index
  within the card's checklist order (clamped). A card-tracking item refuses
  `text` and `done` — rename or move the card it tracks instead.
- `DELETE /api/v1/checklist/{id}` → 204.

### Labels
- `GET /api/v1/projects/{id}/member-labels` → `{member_labels: [{id, name,
  color, holders: [user_id]}]}` — the board's participant titles (card
  #278) and who currently holds each.
- `POST /api/v1/member-labels/{id}/messages` `{body}` → `{channel_id,
  created, message_id}`. Speaks to a title in chat: the room is created on
  the FIRST message ever sent to that label and never before, its
  membership is reconciled to the label's current holders, and the speaker
  does not join. Any board member may speak. Needs chat configured
  (`CHAT_URL`); without it the call reports that plainly.
- `GET /api/v1/projects/{id}/labels` → array of
  `{id, project_id, name, color}` (name-ordered).
- `POST /api/v1/projects/{id}/labels` `{name, color}` → 201 label.
  Duplicate names in a project → 409.
- `PATCH /api/v1/labels/{id}` `{name?, color?}` → label (renames/recolours
  everywhere it's applied).
- `DELETE /api/v1/labels/{id}` → 204 (detaches from every card).
- `GET /api/v1/cards/{id}/labels` → labels on that card.
- `POST /api/v1/cards/{id}/labels` `{label_id}` → 200 (idempotent).
- `DELETE /api/v1/cards/{id}/labels/{label_id}` → 204.

### Priorities
- `GET /api/v1/projects/{id}/priorities` → array of
  `{id, project_id, name, color, position}`, most urgent first. `position`
  is the 0-based rung (0 = most urgent) — the same unit the PATCH takes,
  so echoing a read back never moves anything. Set a card's priority via
  the card PATCH, never here.
- `POST /api/v1/projects/{id}/priorities` `{name, color}` → 201 priority,
  appended at the bottom of the ladder (least urgent; its `position` is
  the last rung). Duplicate names in a project → 409.
- `PATCH /api/v1/priorities/{id}` `{name?, color?, position?}` → priority.
  Omitted fields keep their value; `position` is the new 0-based rung on
  the ladder (0 = most urgent, clamped). Renames/recolours show on every
  card carrying it.
- `DELETE /api/v1/priorities/{id}` → 204 (cards carrying it fall back to
  no priority).

### Members
- `GET /api/v1/projects/{id}/members` → array of
  `{user_id, email, display_name, role}`.
- `POST /api/v1/projects/{id}/members` `{email, role}` → adds an existing
  account immediately or creates a pending invitation. Roles: viewer,
  member, admin.
- `DELETE /api/v1/projects/{id}/members/{user_id}` → 204.

### Attachments
- `POST /api/v1/cards/{id}/attachments` — multipart/form-data with one
  `file` field, max 10 MB → 201
  `{id, card_id, comment_id, filename, content_type, size, uploaded_by,
  created_at}`. Uploads are scanned: programs and installers (`.exe`, `.msi`,
  `.dmg`, `.apk`, …) are always refused, scripts (`.sh`, `.ps1`, `.js`) are
  refused by default (an operator setting, so it varies by deployment), and so
  is anything the virus scanner flags — all as `validation_failed` (422) with
  the reason in `message`, and nothing stored. Archives are not unpacked.
- `GET /api/v1/cards/{id}/attachments` → array (card-level and embedded).
- `GET|POST /api/v1/comments/{id}/attachments` — same, embedded in a comment.
- `GET /api/v1/attachments/{id}` → the file bytes, always as
  `Content-Disposition: attachment`.
- `DELETE /api/v1/attachments/{id}` → 204.

## Errors

Every error uses one envelope:

    { "error": { "code": "…", "message": "…" } }

Codes: `unauthenticated` (401), `forbidden` (403), `not_found` (404 — also
used instead of 403 to avoid confirming a resource exists), `conflict`
(409), `validation_failed` (422), `rate_limited` (429), `internal` (500).

## Limits and behaviour notes

- API-created cards and comments do not run `@`mention parsing: no
  notifications or emails are generated, and no activity-history entries are
  written (the web app does that).
- Titles ≤ 200 chars; label/priority names ≤ 40 chars; checklist items
  ≤ 500 chars; uploads ≤ 10 MB, and refused by type as described above.
- There is no public realtime feed; poll `GET /projects/{id}/columns` for
  board state.
- The web app's column sort ("most recent / oldest / priority / due /
  title / recently active") is a one-shot server-side reorder of `position`
  values — the API sees its results as ordinary position changes.

## Repository binding (git)

Board settings → Repository binds one repo from the ogma-repos hub: commit
messages mentioning a card become comments on that card authored by the
Repos bot. A mention is `#` + the card's per-board number (`#12` — the same
handle the card dialog and card face display; numbers are unique within a
board and never reused). GitHub-connected repos
match anywhere in the commit message and in PR titles/descriptions;
self-hosted pushes currently record subject lines only, so put the mention
in the subject there. Bound repositories also give
the card dialog a create-branch button producing
`card/<number>-<slug>` off the repository's default branch (self-hosted
repositories only). Binding a repository grants no repository access. The
free-text `repo_url` stays as a plain header link when no hub repo is bound.
