# ogma chat — LLM-ready reference

> ogma chat is the team-messaging app in the ogma workspace suite: channels
> (public/private), direct messages, threads, whiteboards, and file
> attachments. This page is the machine-oriented reference for its JSON API
> and core concepts. All paths are relative to the host this file came from;
> the human guide lives at `/help`. MCP clients: the workspace portal host
> serves the core of this API as `chat_*` tools at `/mcp` — see the portal's
> /llms.txt.

## Concepts

- A **channel** is public (anyone in the workspace can browse and join),
  private (invite-only), or a **DM** (a one-to-one conversation with one
  other person).
- **Messages** are Markdown. A message may start a **thread**; replies carry
  the root message's id in `thread_root_id` and don't appear in the main
  timeline. Deleting a message leaves a tombstone (`deleted: true`, empty
  body).
- Mentions (`@name`) notify the mentioned member; posting to a public
  channel you haven't joined joins you to it. **Bots are membership-confined**:
  a bot token sees and posts to exactly its channel memberships — no browsing,
  no join-on-post.
- A **label channel** belongs to one board member label: it is created on the
  first message sent to that label (never when the label is defined), its
  membership IS the label's current holders (the board keeps it in step —
  no manual joins), its name follows the label's, and it claims nothing in
  the global channel namespace, so two boards may both have "architects".
  Leaving one is refused — give up the title, or **mute**. Speaking to a
  label does not join you to its room. Deleting the label archives the room
  (read-only, and leavable, since the title is gone).
- **Mute** is per-member and per-channel: membership and readability are
  unchanged, push/bell/email notifications stop.
- Every message has a **kind**: `text` (the default; the only kind humans
  write), `aside` (a human `!`-prefixed side note in an agent channel — never
  served to bot callers), and the bot-only kinds `tool`, `status`, `notice`,
  `question`, `error` an agent bridge uses. `status` messages never appear in
  timelines — they feed the agent-channel header widget.

## 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: `chat:read` allows GET, `chat:write` is required
to post. Mutating endpoints require `Content-Type: application/json`.
Without a valid token every endpoint returns 401
`{"error":{"code":"unauthenticated", …}}`.

Native clients can mint their own token via the emailed sign-in code:
`GET /api/v1/login-info` (public) names the auth service base URL; then on
THAT host `POST /api/login {email}` sends a 6-digit code (200 whether or not
the account exists) and `POST /api/login/verify {email, code, device_label?}`
exchanges it once for a chat-scoped API token with a 90-day expiry, labeled
per device (revocable in the portal token list).

## Endpoints (`/api/v1`)

Ids are UUIDs. Timestamps are RFC 3339 strings (UTC) — convert to the
device's local time for display. The few *pre-formatted* human-readable
times the server composes (message-ref `hover`/`time` text) render in UTC
unless the request carries an `X-TZ-Offset` header: the caller's offset in
minutes east of UTC (e.g. `600` for UTC+10, `-330` for UTC−5:30).

### Channels
- `GET /api/v1/channels` → `{channels: [{id, kind, name, archived, agent,
  joined, unread, mentions}], features: ["message-kinds", …]}` — everything
  you can see: your channels and DMs, plus
  public channels you could join (`joined: false`). `kind` is `"public"`,
  `"private"`, or `"dm"`; for DMs `name` is the other person's display name.
  `unread` counts top-level messages since your read watermark, `mentions`
  counts mentions of you (thread replies included); both are 0 on
  non-memberships.
- `PATCH /api/v1/channels/{id}` `{name}` — rename. Humans need EditChannel.
  Bots may retitle their own **agent** channel (the harness auto-titles from
  the first real prompt); a pool-worker channel is forbidden.
- `POST /api/v1/channels/{id}/read` → 204 — move your read watermark to
  now. A no-op unless you're a member (viewing never subscribes you).

### People & DMs
- `GET /api/v1/users[?q=…]` → `{users: [{id, name, email, bot}]}` — the
  workspace directory (active accounts), for starting DMs. Capped at 200.
- `POST /api/v1/dms` `{user_id}` → the DM channel with that person
  (find-or-create, idempotent), in the same shape `GET /channels` uses.
- `GET /api/v1/channels/{id}/mentionables` → `{members: [{id, name,
  email}]}` — the channel's members: the valid universe for mentions.

### Search
- `GET /api/v1/search?q=` → `{channels: [{id, name}], messages: [{message_id,
  channel_id, thread_root_id, snippet, author, channel_label, channel_kind}],
  users: [{id, name, email, bot}]}` — the app palette's machinery, scoped to
  what the caller can see: up to 5 channels, 15 messages (full-text;
  `snippet` is the hit in context, `thread_root_id` is set for thread replies,
  `channel_label` is the channel name or the DM peer's display name) and 5
  people from the directory. An empty `q` returns three empty lists.

### Steering (parked messages)
In an **agent channel**, a send can wait for a turn boundary instead of joining
the turn in progress. Neither mode interrupts: a plain send (`send_mode: "now"`,
the default) lands in the channel at once, but mid-turn the harness holds it and
hands everything that arrived over as one coalesced prompt when the turn ends —
only `/stop` cancels a turn. `when_idle` parks the message **server-side**
instead: it is visible to the whole channel, editable and droppable by its
author until it goes, and lands as its own message once the harness reports
idle. Right for an "and then also do X" you may still want to reword.

`POST /channels/{id}/messages` with `send_mode: "when_idle"` → **202** and the
parked row (not a message). Two cases where it declines to park and just sends:
the channel isn't an agent channel (no boundary to wait for), or the agent is
already idle (the boundary is here; waiting again is a slower send). 20 pending
per channel.

The boundary comes from the harness's own status row — chat flushes the queue
when a status reports `state: "idle"` — so this is a typed fact, not a guess. An
unreadable status counts as "not idle": a bad status row must never fire the
queue mid-turn.

- `GET /api/v1/channels/{id}/parked` → `{parked: [{id, channel_id, user_id,
  body, pending, created_at, delivered_at, delivered_message_id}]}`, pending
  first. Everyone in the channel sees the whole queue — these are words about to
  be said out loud to a shared agent.
- `PATCH /api/v1/parked/{id}` `{body}` → the row. **Author only**: being
  editable is the whole advantage of parking over sending, but rewording someone
  else's queued words would put a sentence in their mouth.
- `DELETE /api/v1/parked/{id}` → 204. Both are **409** once delivered — it
  exists, it is just part of the conversation now.

Delivered rows are kept, so "what did I queue, and did it land?" stays
answerable. Each carries an idempotency key minted at *park* time, so two status
posts racing to announce the same boundary cannot say everything twice.

### Fleet
- `GET /api/v1/fleet` → `{agents: [{channel_id, channel_name, bot_user_id,
  bot_display_name, status, status_updated, unread, alert, cards,
  spend_today}], cards_unavailable}` — every agent channel the caller is in,
  with its live state, in **one** call. This feeds a single screen (the Flutter
  client's mission control — the web app has no fleet page); a request per
  agent shows up as a screen that fills in raggedly.

  `agent_id` / `agent_name` are still served as deprecated aliases of
  `bot_user_id` / `bot_display_name`, for the Flutter build already in the
  field. They carry the **bot account's** id and display name — never the
  coordinator's `agents.id` — which is why they are being renamed; see
  `docs/agent-vocabulary.md`. Read the new names.

`status` is the same blob `channels/{id}/agent-status` serves, null until the
agent has posted one. `status_updated` is RFC-3339; the *staleness judgement is
the client's* — an agent claiming to run but no longer saying so is only
interesting relative to how long someone has been watching.

Two nulls are deliberate. `spend_today` is today's spend in dollars, read from
the coordinator's usage ledger (fed by the `usage` reports below; micros
converted) — and **null, not 0**, whenever the ledger didn't answer for that
agent: "not measured" and "measured as zero" are different claims, and a
confident zero is a lie a dashboard repeats. And
`cards_unavailable: true` means the boards app was unreachable, so empty `cards`
means "couldn't ask" rather than "nothing claimed"; a screen that can't tell
those apart tells the reader something comforting and wrong.

Realtime: refetch on the existing SSE `agent` event rather than opening a second
stream — it already fires on every status change.

### Agent workspace (read-only)
An agent channel's `/workspace`, browsed through chat so a client keeps one
host and one token. Authorization is channel membership: a non-member gets the
channel's own 404, a channel with no agent is a 422, and a stopped agent is a
409 saying so — the workspace only exists while the container runs.

- `GET /api/v1/channels/{id}/workspace?path=` → `{path, entries: [{name, path,
  kind, size, modified}], truncated}` — one directory, `path` relative to the
  root (empty = the root), directories first then by name. `kind` is `dir`,
  `file`, or `other` (symlinks, sockets — listed, never followed); `modified`
  is unix seconds. `truncated: true` means the 500-entry cap cut the listing.
- `GET /api/v1/channels/{id}/workspace-file?path=` → `{kind: "text", path,
  content, truncated, size}` (cut at 512 KiB) or `{kind: "binary", path, size,
  media_type}` — binary is described, never mangled.

### Agent bookkeeping (bot callers only)
What a harness files with the coordinator through chat, which forwards it
under the identity it authenticated — an agent cannot report against another
agent. A human token gets a 422; the bot must be a member of the channel.

- `POST /api/v1/channels/{id}/usage` `{provider, model, input?, output?,
  cache_read?, cache_write?, estimated?, turn_key}` → `{recorded, cost_micros,
  today_micros, attribution}` — what the last turn cost. `turn_key` (1–200
  chars) is the agent's own name for the turn, so a retry can't double-count;
  `today_micros` is the agent's running total for the day.
- `POST /api/v1/channels/{id}/actions/decide` `{tool_name, target, turn_key,
  mcp_server?}` → `{id, decision, rule, reason, secret_used}` — the fail-closed
  outbound action gate. `decision` is `permit` or `refuse`; an error here is a
  reason to abort, not to proceed.
- `POST /api/v1/channels/{id}/actions/{id}/complete` `{outcome}` → 204 —
  close the row; `outcome` is `success`, `fail`, or `aborted`.

### Alerts
The tier that buzzes a pocket whether or not you look busy elsewhere. The
ordinary push path is deliberately polite — a mention holds for a grace period
while you're reading on another device. An alert is the case where politeness is
the bug: something is stopped and nothing moves until a person answers.

- `POST /api/v1/channels/{id}/alerts`
  `{title, body?, severity?, kind?, claimed_origin?}` → 201 alert. Posts a
  message into the channel (so the conversation holds the record) AND pushes to
  the channel's people, skipping the raiser. The fan-out is awaited, so the
  response reports what actually happened.
  - `severity`: `info`, `warning` (default), `urgent`. **Only `urgent`** skips
    the presence grace period, rides APNs `time-sensitive` presentation and
    collapses on its own key so channel chatter can't bury the banner.
  - `kind`: `needs_input`, `blocked`, `failed`, `finished`, `rate_limited`,
    `other` (default). A closed set, because clients route and render on it.
  - `claimed_origin`: what you are relaying for, recorded verbatim beside the
    identity the server authenticated. A mismatch is flagged
    (`origin_mismatch`) and logged; the claim never replaces the fact.
- `GET /api/v1/channels/{id}/alerts` → `{alerts: […]}`, newest first, 50 max.
  Includes suppressed ones — a run of them is the shape of something stuck.

Alert shape: `{id, channel_id, raised_by, claimed_origin, origin_mismatch,
severity, kind, title, body, message_id, delivered_to, suppressed, created_at}`.

Two fields exist so an alert is *checkable* rather than just loud.
`delivered_to` is how many devices took it — 0 with `suppressed: false` means
nobody has a device registered, which is worth knowing and invisible without it.
`suppressed: true` means it was collapsed into an alert of the same channel and
kind raised within the last **60 seconds**; it is still recorded, because "I
alerted and nothing happened" and "I alerted and it was deduped" need different
responses, and a caller that can't tell them apart will alert harder.

Deduping is keyed on channel + kind, not on the text: a retry loop tends to
reword itself, and a dedupe defeated by rephrasing is no dedupe at all.

### Saved messages
Reusable message text belonging to the caller — the canned prompts a person
inserts into the composer instead of retyping a standing instruction. Personal,
always: a request only ever sees and touches its own caller's snippets, and
someone else's id is a 404 rather than a 403.

- `GET /api/v1/saved-messages[?channel_id=]` → `{saved_messages: [{id,
  channel_id, title, body, position}]}`. With `channel_id`, returns what a
  composer there should offer — that channel's snippets plus the ones scoped to
  nothing (channel-scoped first).
- `POST /api/v1/saved-messages` `{title, body, channel_id?}` → 201. `title` is
  1–80 characters, `body` 1–4000. `channel_id` scopes it to one channel (you
  must be able to post there); absent = everywhere. 50 per person.
- `PATCH /api/v1/saved-messages/{id}` `{title?, body?}` → the snippet. Scope is
  fixed at creation — a snippet that quietly changed which channels it appeared
  in would be a surprise nobody asked for.
- `DELETE /api/v1/saved-messages/{id}` → 204.

### Messages
- `GET /api/v1/channels/{id}/messages` → `{messages: [message…]}` — the
  latest page of top-level messages, oldest → newest. Page with exactly one
  of `?before=<message id>` (older) or `?after=<message id>` (newer).
  Thread replies never appear here; a root's `reply_count` tells you a
  thread exists — read it via the thread endpoint below.
- `POST /api/v1/channels/{id}/messages`
  `{body, thread_root_id?, kind?, idempotency_key?}` → 201 message. `body` is
  Markdown. Posting runs the full composer pipeline: join-on-post, mentions
  (with notifications/emails), read-state bump, realtime fan-out to open
  clients. `kind` is bot-only (422 otherwise); posting `kind: "status"`
  **upserts** the caller's single status row per channel (200 on edit-in-place,
  201 on first create).

  `idempotency_key` makes a send exactly-once: any string you choose, unique
  per intended message, at most 100 characters. A repeat with the same key
  returns the ORIGINAL message with **200** instead of delivering a second one,
  so retrying after a timeout is safe — the status code tells you which
  happened. Reusing a key for different text (or a different channel) is a
  **409**, not a silent success handing you something you didn't send. Keys are
  scoped to the sender and kept for 24 hours.

  Use one whenever a duplicate would matter. Steering an agent especially: a
  repeated instruction is a second unit of work, not a cosmetic blemish.
- `PATCH /api/v1/channels/{id}/messages/{message_id}` `{body, final?}` →
  200 message — edit your own message through the same pipeline as the
  app's inline edit. Exists for callers that live-update a message they
  stream into (the agent bridge); bot edits sync mention rows on every
  PATCH but only send mention emails when `final: true`.
- `DELETE /api/v1/channels/{id}/messages/{message_id}` → 204 — the app's
  delete policy over the API: your own message always, anyone's with
  channel owner/admin moderation rights. The row stays as a "message
  removed" tombstone (the timeline keeps its shape); its attachments and
  mention rows are cleaned up with it.
- `GET /api/v1/messages/{id}/thread` → `{root: message, replies:
  [message…]}` — a whole thread, oldest → newest, unpaged. Any id in the
  thread works: a reply id resolves up to its root.

### Attachments
- `GET /api/v1/attachments/{id}` → the raw file bytes
  (`Content-Disposition: attachment`). Uploading stays app-only; messages
  reference what was uploaded in the app via their `attachments` array.

Message shape: `{id, channel_id, thread_root_id, author: {id, name, bot},
body, kind, deleted, reply_count, last_reply_at?, attachments: [{id,
filename, size_bytes, content_type, image}], refs?: [{url, kind, label,
hover?}], created_at, edited_at}`. `last_reply_at` (RFC 3339) is when the
newest thread reply landed; omitted while `reply_count` is 0. Bot callers never receive `aside` or `status` messages in any
listing. `refs` lists the ogma references (see below) resolved *for the
caller* on list/thread reads — `kind` is card|board|commit|branch, `label` the
chip text (`#12`, board name, short sha), `hover` the card title / commit
subject. Omitted when empty and on post/edit responses; links absent from
`refs` should render as plain links.

### Mentions (writing them)
A mention is a Markdown link whose target is the member's id:
`[@Alice Park](/u/<user id>)` — ids come from the mentionables endpoint.
The link text is a display-name snapshot (free text); matching is by id,
and ids that aren't channel members are ignored. `#channel` refs work the
same way: `[#general](/c/<channel id>)`.

- `GET /api/v1/resolve-ref?url=` → `{url, kind, label, hover?}` — resolve
  ONE ogma URL for the caller (same per-viewer rules as `refs` below);
  404 = not a reference or not visible. Composers use it to collapse a
  pasted link into a labeled pill before sending.
- `GET /api/v1/link-preview?url=` → `{title, site, description?}` — the
  hover card for a plain external link: page title and the final host
  (`www.` stripped). 404 for anything that isn't a usable preview — previews
  disabled, a refused or dead address, a page with no metadata, a URL over
  2048 characters — and the hover then shows nothing.

### Ogma references (cards / boards / commits / branches)
A message containing an absolute projects card link
(`{projects}/projects/{project_id}?card={card_id}`), a board link
(`{projects}/projects/{project_id}`), a hosted commit link
(`{repos}/r/{name}/commit/{sha}`), or a hosted branch link
(`{repos}/r/{name}/tree/{branch}` — only when the rest is EXACTLY a branch
name; branch+path deep links stay links) renders as a compact chip for
viewers who can see the target (card `#12` + title on hover, board name,
short sha / branch name + tip subject on hover); everyone else keeps the
plain link. The URL is the
durable reference — write it completely bare in the text, as an autolink
(`<…>`), or with any snapshot text (`[#12](…)`); labels re-resolve at
render time. Bare http(s) URLs in message text are auto-linked
(GFM-style) on render, so they click and chip like written links.

### Calls (1:1 voice)
Voice calls are **DM-only** and peer-to-peer: the server carries signaling,
never media. Every endpoint takes the DM's own channel authorization; the
`call` SSE event says only "call X changed", and the drain answers with what.

Call shape: `{id, channel_id, caller_id, callee_id, peer_id, incoming, state,
end_reason, started_at, answered_at, ended_at}` — `peer_id` is the other party
from the caller's point of view, `incoming` is true for the one being rung.
`state` runs `ringing` → `connecting` → `active` → `ended`; `end_reason` is
then `hangup`, `declined`, `missed`, `busy`, `failed` (negotiation never
completed) or `dropped` (both peers went quiet).

- `GET /api/v1/calls/ice` → `{iceServers: [{urls, username?, credential?}]}`
  — fetch at call start, not page load (TURN credentials go stale); STUN only
  when no TURN is configured.
- `POST /api/v1/channels/{channel_id}/calls` → 200 call — ring the other
  party. A busy or offline callee still gets 200, with an already-ended call
  rather than an error.
- `GET /api/v1/calls/{call_id}/signals?since=N` → `{call, signals: [{seq,
  from_user_id, payload}]}` — the call as it is now plus every signal after
  `seq` N (0 = everything).
- `POST /api/v1/calls/{call_id}/signals` `{payload}` → `{seq}` — an SDP
  offer/answer or ICE candidate, opaque to the server.
- `POST /api/v1/calls/{call_id}/accept` `{device_token?}` → call. The
  optional `device_token` is the answering device's own push registration, so
  the retraction that silences your other devices spares this one.
- `POST /api/v1/calls/{call_id}/decline` → call.
- `POST /api/v1/calls/{call_id}/hangup` → call.
- `POST /api/v1/calls/{call_id}/connected` → call — a peer reports media is
  flowing (`connecting` → `active`); the server can't see that itself.

## Push tokens

Mobile clients register their APNs/FCM device tokens so DM messages and
@mentions reach a closed app. Both endpoints need `chat:write`.

    POST /api/v1/push-tokens
    { "platform": "ios" | "android", "token": "<device token>" }
    → 204

The token is the identity: re-registering an existing token moves it to the
caller. Repeat the POST whenever the platform rotates the token.

    DELETE /api/v1/push-tokens
    { "platform": "ios" | "android", "token": "<device token>" }
    → 204 (idempotent; only the caller's own registration is removed)

Push policy mirrors unread badges: fresh top-level DM messages and new
@mentions notify; plain channel traffic, edits, thread replies and
status/notice rows stay silent. Dead tokens (APNs 410, FCM UNREGISTERED)
are pruned automatically.

## 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 channel exists), `conflict` (409),
`validation_failed` (422), `rate_limited` (429), `internal` (500).

## Limits and behaviour notes

- Still lean by design: reactions, uploads, and whiteboards stay app-only.
- Messages are capped at 10,000 bytes of Markdown.
- Realtime: `GET /api/v1/events` is the same SSE change-signal stream the web
  client rides, open to Bearer callers with `chat:read` (named events such as
  `agent`, `call`, `member`; a signal says *what changed*, not the content —
  refetch). Polling `?after=<last seen id>` still works as the fallback.

## Repository notices

A channel owner can bind a repository from the ogma-repos hub in channel
settings; its pushes then land as notice-kind messages authored by the Repos
bot (muted chrome, in the timeline, counted in unreads).
