# 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.

## 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), 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, 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?}` →
  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).
- `DELETE /api/v1/columns/{id}` → 204. Deletes the cards in it.

### 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"`.
- `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; `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 assignee (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, priority_id, due_at, owner_type, shepherd_id,
shepherds, work_phase, attention, created_at, updated_at, archived_at,
checklist_done, checklist_total, 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.
`shepherds` are the watchers accountable for reviewing the work, as opposed
to the `assignee_id` doing it — `[{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).
`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).
`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
  (unassigned → you), 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.

`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.

### 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.

### Comments
- `GET /api/v1/cards/{id}/comments` → array of
  `{id, card_id, author_id, author_name, body, created_at, edited_at}`
  (oldest first; `edited_at` is null until the author edits).
- `POST /api/v1/cards/{id}/comments` `{body}` → 201 comment. `body` is
  Markdown.
- `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).
  `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.
