# Board API

`folio-board-server api` serves one versioned HTTP API, locally on loopback or
behind HTTPS. The live kanban calls it; so can a script.
[Local Git](/docs/guides/live), [hosted GitHub](/docs/deployment/deploy) and
[SQLite](/docs/deployment/sqlite) guides cover the setup.

```bash
curl -s http://127.0.0.1:4350/v1/health \
  -H "Authorization: Bearer $TOKEN"
```

```json
{"store":"local","location":"/home/ana/widgets/board","read_only":false,"actor":"ana","rev":"6ade54d…"}
```

A hosted server also names the board's `source`, its repository and branch,
and `actor` is the signed-in login: `"source":"acme/roadmap@board"`.

A SQLite server reports `"store":"sqlite"`, its database location and a
source such as `"sqlite:board"`. It uses the same routes and file hashes;
revision identifiers address database snapshots rather than Git commits.

## Every request carries the bearer

| Header | When | Meaning |
| --- | --- | --- |
| `Authorization: Bearer <token>` | every board route | The local or standalone bearer, a signed account session, or a source-bound API token. |
| `Host` | always | The configured public host, or the loopback host locally; anything else gets 421. |
| `If-Match: "<hash>"` | card edits except comments, trail notes and attaching | The card's `hash` from the last read. Creating a card needs no existing hash. |
| `Content-Type: application/json` | writes with a body | Bodies are at most 256 KB. |

A card's hash is the git blob SHA-1 of its file. `GET /v1/cards/{id}` returns
it as `hash` and as the `ETag` header; every write returns the new one.

## Routes read the board and edit one card at a time

| Route | Body | Returns |
| --- | --- | --- |
| `GET /healthz` | — | Public readiness: `{"status":"ready"}`. No bearer required. |
| `GET /v1/tokens` | — | This signed-in account's token metadata; never their secrets. Account mode only. |
| `POST /v1/tokens` | `{"name":"Terminal","source":{"kind":"sqlite","id":"roadmap","params":{}},"permission":"read","expires_in_days":30}` | A one-time `token` and its `key` metadata. Normal account session required. |
| `DELETE /v1/tokens/{id}` | — | Revokes this account's token; 204. API tokens cannot manage tokens. |
| `POST /v1/ops` | An operation request | Applies up to 32 operations to one card. Supports `if_match` and `dry_run`. |
| `GET /v1/sources` | — | Authorized source descriptors, provider fields, `install_url` and provider-specific warnings. |
| `POST /v1/sources/options` | `{"kind":"github","id":"owner/repo","params":{"branch":"board","path":""}}` | Provider-defined configuration fields and options. Database sources need no branch or folder. |
| `POST /v1/sources/open` | `{"kind":"sqlite","id":"roadmap","params":{}}` | A `token` bound to the selected source; the same route accepts GitHub references. |
| `GET /v1/repositories` | — | The signed-in person's authorized repositories and `install_url`. Account mode only. |
| `GET /v1/repositories/branches` | `?repo=owner/name` | Branch names for an authorized repository. Account mode only. |
| `POST /v1/repositories/open` | `{"repo":"owner/name","branch":"board","path":""}` | A new `token` bound to this board. The directory must contain a valid board. |
| `POST /auth/logout` | — | Invalidates the account's credentials and every board token from that sign-in. |
| `GET /v1/health` | — | The store, its location, `read_only`, the actor and the head revision. |
| `GET /v1/board` | — | `rev`, `title`, `columns` with their card ids, `icons`, every card, and `warnings`. |
| `POST /v1/cards` | `{"id": "first-idea", "markdown": "---\ntitle: First idea\nstatus: ideas\n---\n"}` | Creates a validated card through the selected store and returns the outcome with status 201. Existing ids are refused. |
| `GET /v1/cards/{id}` | — | One card: `hash`, `fields`, `description`, `criteria`, `comments`, `trail`, `artifacts`. |
| `POST /v1/cards/{id}/move` | `{"status": "done", "after": "other-card"}` | The outcome. `after` is optional; the anchor must be in the destination column and carry an `order:` rank, as for [`move --after`](/docs/reference/cli#move). |
| `PATCH /v1/cards/{id}/fields` | `{"set": {"priority": "high", "size": null}}` | The outcome. `null` removes the field. |
| `PUT /v1/cards/{id}/description` | `{"markdown": "…"}` | The outcome. |
| `POST /v1/cards/{id}/criteria` | `{"text": "…"}` | The outcome. Appends one open criterion. |
| `PUT /v1/cards/{id}/criteria/{n}` | `{"done": true}`, `{"text": "…"}` or both | The outcome. `n` counts from 0. Both in one request commit as `board: update <id>`. |
| `POST /v1/cards/{id}/comments` | `{"text": "…"}` | The outcome. |
| `POST /v1/cards/{id}/trail` | `{"note": "…", "ref": "abc1234"}` | The outcome. `ref` is optional. |
| `POST /v1/cards/{id}/artifacts` | `{"kind": "pr", "target": "42", "label": "…"}` | The outcome. `kind` is `pr`, `url`, `doc` or `file`. A `doc` or `file` must already be a file of the board. |
| `DELETE /v1/cards/{id}/artifacts/{n}` | — | The outcome. Removes the `n`-th `artifacts:` entry, from 0. |

`fields` accepts the fields [`update`](/docs/reference/cli#update) takes, plus `tags`,
`blocked_by` and `link`; `tags`, `blocked_by` and `assignee` take a list.
`GET /editor/v1.js` returns the description editor's script and needs no
bearer.

A `doc` or `file` target is a path from the card's directory, such as
`report.md`, or from the board as `board/cards/<id>/report.md`. A file
elsewhere in the project, such as `docs/research/analysis.md`, gets
`400 invalid_input`. For a Git board, attach it with
[`folio-agents board attach`](/docs/reference/cli#attach). SQLite file artifacts
refer to files imported with the seed; the API does not upload new file contents.

## A hosted GitHub server adds three sign-in routes

With a GitHub store and `board.server.hosted` set, the server answers its public
host, signs people in with GitHub and takes a session instead of the printed
bearer. SQLite uses the same login when `board.auth.provider: github` is selected.
Explicit `provider: token` uses the configured workspace bearer instead.

| Route | Body | Returns |
| --- | --- | --- |
| `GET /auth/login` | `?return_to=<page>`, a page of `board.server.origins` | A redirect to GitHub. |
| `GET /auth/callback` | GitHub's `code` and the signed `state` | A redirect to `<page>#board-code=<code>`, a one-time code valid 60 seconds. |
| `POST /auth/exchange` | `{"code": "..."}` | `token`, `login`, `write` and `expires_at`. The token is the bearer for 8 hours. |

In account mode, sign-in does not require access to the deployment repository.
The person lists connected repositories, chooses a branch and directory, and
exchanges that selection for a board-bound session. A board request without a
selection answers `400 invalid_input`. A repository outside both the person's
access and the App installation answers `403 read_only`. GitHub permissions
are checked again on each read and write. Readers cannot edit. Each commit is
authored by the signed-in person and committed by the server.

With `FOLIO_BOARD_MULTI_REPO=false`, the server serves the single repository configured
in `board.store`; only its collaborators can read it.

A hosted server clones the board's branch at start into `--workdir`, replacing what was there, and reads its secrets from the environment: `FOLIO_BOARD_SESSION_KEY` (at least 32 bytes), `FOLIO_BOARD_GITHUB_CLIENT_SECRET`, and its own credential for the repository. That credential is a GitHub App installed on the repository, whose private key is in `FOLIO_BOARD_GITHUB_APP_KEY` or in the file `FOLIO_BOARD_GITHUB_APP_KEY_FILE` names: the server signs in as the App and renews its one-hour installation token on its own. Without an App key, it takes a token from the variable `board.store.token_env` names. People sign in with the same App, whose client id is `board.server.hosted.github_client_id`.

```bash
folio-board-server api --project-dir . --workdir /tmp/folio-board-clone
```

## A write returns its revision

Card creation records `board: add <id>`, on either store.

Git stores create a commit. SQLite creates a database revision; the `commit`
response field carries its identifier and message, with `url: null`.

```json
{
  "rev": "f607a94…",
  "changed": true,
  "commit": {"sha": "f607a94…", "message": "board: ship-the-alpha done -> ideas", "url": null},
  "card": {"id": "ship-the-alpha", "hash": "b98e2fd…", "status": "ideas", "…": "…"},
  "diff": "",
  "warnings": []
}
```

A write whose result equals the card's current bytes commits nothing and
returns `"changed": false`. The server writes the commit message; a request
cannot set one. The messages are those of the [CLI](/docs/reference/cli#--commit), plus
`board: describe <id>`, `board: check criterion on <id>`,
`board: update criterion on <id>`, `board: add criterion on <id>` and
`board: detach <kind> from <id>`. A request that makes two changes, such as a
criterion's `done` and `text`, commits as `board: update <id>`.

## One error envelope, ten codes

```json
{"error": {"code": "precondition_failed", "message": "'ship-the-alpha' changed since you read it",
           "hint": "review the current card and retry with its hash", "retryable": false,
           "current": {"id": "ship-the-alpha", "hash": "b98e2fd…", "…": "…"}}}
```

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_input` | The body is not the JSON the route takes, or a value is refused, such as a `parent` that makes a cycle. |
| 401 | `unauthorized` | The bearer is missing or wrong. |
| 403 | `read_only` | `board.server.read_only` is `true`. |
| 404 | `not_found` | No such card, criterion, artifact entry or route. |
| 409 | `stale` | The board moved under three attempts in a row. Retry. |
| 412 | `precondition_failed` | The card changed since the `If-Match` hash; `current` holds it now. |
| 421 | `misdirected` | The `Host` header does not name the server's loopback address. |
| 422 | `board_invalid` | The edited card would not load, such as a description that adds a `## Trail` heading; nothing was written. |
| 428 | `precondition_required` | The write needs `If-Match`. |
| 502 | `backend` | Git or the file system failed. Retry. |

`retryable` is `true` for `stale`, and for a `backend` failure of git or the
file system on `/v1/board`, a card read or a write, where the same request can
succeed. Two `backend` answers carry `false`: a store failure on `/v1/health`,
and a request whose worker task failed inside the server.

Three answers bypass the envelope and carry a plain-text or empty body:

| Status | When |
| --- | --- |
| 400 | A path index, such as `{n}`, is not a number from 0. |
| 405 | The route exists but not with that method. |
| 413 | The body is over 256 KB. |

## No MCP server ships in this release

Not available in this release. Scripts and agents use this API for Git or
SQLite. `folio-agents board` and the packaged skills operate Markdown/Git
directories; they do not edit SQLite. [Tool support](/docs/deployment/storage#cli-skills-http-and-mcp)
compares the available interfaces.

## Operation requests

`POST /v1/ops` and MCP's `card_apply` call the same domain service. Set `card`
to the id, `ops` to the operation list, `if_match` to the hash returned by
`GET /v1/cards/{id}`, and `dry_run` to preview without committing.

```json
{"card":"improve-search","ops":[{"op":"move","status":"in-progress"}],"if_match":"<current hash>","dry_run":false}
```

The service accepts move, update, set_description, toggle_criterion,
set_criterion_text, add_criterion, comment, trail, attach and detach operations.
Create a card with `POST /v1/cards` or MCP's `card_create` instead of an `op:create`.
Every write retains normal read-only and stale-hash checks. Neither actors nor
commit messages are request fields. See [API tokens](/docs/deployment/tokens)
and [MCP](/docs/deployment/mcp).
