# kanban-server
HTTP API server for kanban project management. Wraps `kanban-service` behind a REST interface so non-Rust clients (web UIs, scripts, other services) can read and write boards without going through the TUI, CLI, or MCP server.
**Status: early / minimal.** Only boards and column reads are wired up so far — see [Endpoints](#endpoints). The bind address is configurable (see [Configuration](#configuration)); per-request logging is not. Still best treated as a development server rather than a hardened production deployment.
## Architecture
`kanban-server` holds a single `KanbanContext` in memory behind a `tokio::sync::Mutex`, shared across all handlers via axum's `State`.
```mermaid
graph TD
CLIENT[HTTP client] -->|JSON over HTTP| SRV[kanban-server
axum Router]
SRV --> STATE[AppState
Arc/Mutex-wrapped KanbanContext]
STATE --> SVC[KanbanContext
kanban-service]
SVC --> STORE[PersistenceStore]
STORE --> STORAGE[*.json / *.sqlite]
```
A `tokio::sync::Mutex` is used rather than a sync `RwLock`: `KanbanContext`'s write path (`save`/`reload`) is async, and holding a sync write guard across an `.await` would be a `Send`/deadlock hazard.
Each successful mutation broadcasts a `ChangeEventFrame` on an in-process `tokio::sync::broadcast` channel (`AppState::broadcast_change`), naming the entity type, id and change kind (created/updated/deleted) it touched. `GET /v1/events` streams these frames to clients over SSE, so a client can invalidate just the affected board, column, card or sprint instead of its whole cache. A frame caused by an external process writing the file directly (`AppState::broadcast_unscoped_change`) carries no entity identity, meaning subscribers must invalidate everything.
## Installation
### From Nix (recommended)
```bash
nix build .#kanban-server
```
### From Cargo
```bash
cargo install --path crates/kanban-server
```
## Usage
```bash
kanban-server
```
On startup the server opens (or creates) the board file, binds the configured address (default `127.0.0.1` on an OS-assigned ephemeral port), and serves until killed. Pin a fixed host/port with the `--addr` flag, the `KANBAN_ADDR` env var, or the `server_addr` config key (see [Configuration](#configuration)). When left on the default ephemeral port, read the bound address from the startup log line (`RUST_LOG=info`) or `lsof -p `.
### Configuration
| Env var | Default | Purpose |
|---|---|---|
| `KANBAN_FILE` | `kanban.json` (in the working directory) | Storage locator, resolved through the same backend registry as the CLI/TUI/MCP server — a `.json` path uses the JSON backend, a `.sqlite`/`.db` path (or existing SQLite file) uses the SQLite backend. |
| `KANBAN_ADDR` | `127.0.0.1:0` (ephemeral loopback) | Address the HTTP server binds, as `host:port` where host is an IP literal (`127.0.0.1`, `0.0.0.0`, `[::1]`); hostnames such as `localhost` are not resolved. Resolved with the same layered precedence as `KANBAN_FILE`: the `--addr` flag wins, then `KANBAN_ADDR`, then the `server_addr` key in the config file, then the default. Set `0.0.0.0:` to accept non-loopback connections (e.g. behind a reverse proxy). |
| `RUST_LOG` | unset (⇒ `error` only) | Standard `tracing-subscriber` env filter. Set to `info` to see the startup log line; there is no per-request access logging. |
The bind address can also be set with the `--addr` flag or the `server_addr` key in the kanban config file (`~/.config/kanban/config.toml`); the resolution order is `--addr` > `KANBAN_ADDR` > `server_addr` > the `127.0.0.1:0` default.
### Example
```bash
RUST_LOG=info KANBAN_FILE=/path/to/boards.json kanban-server
# 2026-07-27T20:56:12Z INFO kanban_server: kanban-server listening addr=127.0.0.1:58548
```
```bash
curl -s http://127.0.0.1:58548/health | jq
```
```json
{
"status": "ok",
"instance_id": "079131c7-ffac-4269-9dc6-5dee6af77097"
}
```
`instance_id` is a random UUID generated once per process start (`AppState::new`) — stable across requests within a run, and useful for a client to detect a server restart.
```bash
curl -s http://127.0.0.1:58548/v1/boards | jq
```
```json
{
"items": [
{
"id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
"name": "Kanban",
"description": "Management of the **Kanban** project\n",
"sprint_prefix": "KAN",
"card_prefix": "KAN",
"task_sort_field": "updated_at",
"task_sort_order": "descending",
"sprint_duration_days": 7,
"task_list_view": "grouped_by_column",
"active_sprint_id": "2ab2a4d3-80d0-4bd6-881c-88bed5fd7670",
"position": 0,
"created_at": "2025-10-10T08:47:44.779097Z",
"updated_at": "2026-07-04T10:55:00.488151029Z"
}
],
"total": 1,
"page": 1,
"page_size": 50,
"total_pages": 1
}
```
```bash
curl -s http://127.0.0.1:58548/v1/boards/e119c091-e1fa-4596-9bc7-038ceab6adec/columns | jq '.items[].name'
```
```json
"Backlog"
"In Progress"
"Done"
```
Creating a board (`POST`) returns `201` with the same `BoardResponse` shape as the reads above:
```bash
curl -s -X POST http://127.0.0.1:58548/v1/boards \
-H 'content-type: application/json' \
-d '{"name": "Roadmap", "card_prefix": "RM"}' | jq
```
```json
{
"id": "3fbb2b8b-...",
"name": "Roadmap",
"card_prefix": "RM",
...
}
```
A lookup miss comes back as the error envelope, not an empty body:
```bash
curl -s http://127.0.0.1:58548/v1/boards/00000000-0000-0000-0000-000000000000 | jq
```
```json
{
"code": "NOT_FOUND",
"message": "Board 00000000-0000-0000-0000-000000000000 not found"
}
```
## Endpoints
All request/response bodies are JSON. Errors share one envelope (see [Error Handling](#error-handling)). The four collection `GET`s (`/v1/boards`, `/v1/boards/{board_id}/columns`, `/v1/boards/{board_id}/cards`, `/v1/boards/{board_id}/sprints`) accept `?page=&page_size=` and return a `Page` envelope; see [Pagination](#pagination).
### Health
| Method | Path | Description |
|---|---|---|
| `GET` | `/health` | Liveness check. Returns `{"status": "ok", "instance_id": ""}`. |
### Boards
| Method | Path | Description | Body |
|---|---|---|---|
| `GET` | `/v1/boards` | List all boards. Returns `Page`; accepts `?page=&page_size=`. | — |
| `GET` | `/v1/boards/{id}` | Get a board by UUID. Returns `archived_at` (present only if the board is archived). | — |
| `POST` | `/v1/boards` | Create a board. `201 Created`. A board created this way always has zero columns. | `CreateBoardRequest` |
| `PUT` | `/v1/boards/{id}` | Full replace (RFC 9110 §9.3.4) — creates the board at `id` if absent (`201`), otherwise replaces it in full (`200`). All non-nullable fields are required; a partial body is a 400. | `ReplaceBoardRequest` |
| `PATCH` | `/v1/boards/{id}` | Partial update — JSON Merge Patch (RFC 7386): an absent field is no change, `null` clears it, a value sets it. | `UpdateBoardRequest` |
| `DELETE` | `/v1/boards/{id}` | Delete a board and everything under it. `204 No Content`. | — |
### Columns
| Method | Path | Description |
|---|---|---|
| `GET` | `/v1/boards/{board_id}/columns` | List a board's columns. 404s if `board_id` doesn't exist (does not collapse into an empty list). Returns `Page`; accepts `?page=&page_size=`. |
| `GET` | `/v1/boards/{board_id}/columns/{id}` | Get a column by UUID. 404s if the column exists but belongs to a different board. |
Column writes (create/update/delete) aren't implemented yet.
### Sprints
| Method | Path | Description | Body |
|---|---|---|---|
| `GET` | `/v1/boards/{board_id}/sprints` | List a board's sprints. 404s if `board_id` doesn't exist (does not collapse into an empty list). Returns `Page`; accepts `?page=&page_size=`. | — |
| `GET` | `/v1/boards/{board_id}/sprints/{id}` | Get a sprint by UUID. 404s if the sprint exists but belongs to a different board. | — |
| `POST` | `/v1/boards/{board_id}/sprints` | Create a sprint. `201 Created`. A client-supplied `id` that already exists is a `409 Conflict`. | `CreateSprintRequest` |
| `PUT` | `/v1/boards/{board_id}/sprints/{id}` | Full replace (RFC 9110 §9.3.4) — creates the sprint at `id` if absent (`201`), otherwise replaces it in full (`200`). 404s if `id` belongs to a different board. | `ReplaceSprintRequest` |
| `PATCH` | `/v1/boards/{board_id}/sprints/{id}` | Partial update — JSON Merge Patch (RFC 7386). 404s if the sprint belongs to a different board. | `UpdateSprintRequest` |
| `DELETE` | `/v1/boards/{board_id}/sprints/{id}` | Delete a sprint. `204 No Content`. 404s if the sprint belongs to a different board. | — |
| `GET` | `/v1/sprints/{id}` | Flat alias for the board-scoped `GET`. | — |
| `PATCH` | `/v1/sprints/{id}` | Flat alias for the board-scoped `PATCH`. | `UpdateSprintRequest` |
| `DELETE` | `/v1/sprints/{id}` | Flat alias for the board-scoped `DELETE`. | — |
### Cards
| Method | Path | Description | Body |
|---|---|---|---|
| `GET` | `/v1/boards/{board_id}/cards` | List a board's cards. 404s if `board_id` doesn't exist (does not collapse into an empty list). Supports `?column_id=`, `?sprint_id=` and `?archived=` filters alongside pagination. Returns `Page`; accepts `?page=&page_size=`. | — |
The remaining card routes (get/create/replace/update/delete, and the flat `/v1/cards/{id}` aliases) exist but aren't documented in this table yet.
### Graph
| Method | Path | Description | Body |
|---|---|---|---|
| `GET` | `/v1/cards/{id}/graph` | The card's dependency edges, scoped to that card: parents/children (spawns), blocked_by/blocks and related. Only active edges; archived edges are omitted. 404s if the card does not exist, rather than returning empty arrays. | — |
### Events
| Method | Path | Description |
|---|---|---|
| `GET` | `/v1/events` | Server-Sent Events stream of `ChangeEventFrame`s, one per successful mutation (or per detected external write). Each frame carries `entity_type`/`entity_id`/`kind`, all absent when the emitter cannot name what changed. |
Every write route (`POST`/`PUT`/`PATCH`) broadcasts a change event naming the entity it touched and, per the persistence layer's normal save path, durably writes to the configured store before responding.
## Pagination
`GET /v1/boards`, `GET /v1/boards/{board_id}/columns`, `GET /v1/boards/{board_id}/cards` and `GET /v1/boards/{board_id}/sprints` accept `?page=` (1-based) and `?page_size=`, both optional, and return a `Page` envelope:
```json
{ "items": [...], "total": 42, "page": 1, "page_size": 50, "total_pages": 1 }
```
- `page` defaults to `1`, `page_size` defaults to `50`.
- `page_size` is capped at `500`; `page=0`, `page_size=0` or `page_size` over the cap is a `422 VALIDATION_FAILED`.
- A `page` past the last page is a normal `200` with `items: []`; `total` still reports the true, unfiltered count.
- `total_pages` is `0` for an empty collection.
- Slicing is in-memory: the full collection is read from storage first, then windowed. There is no store-level `LIMIT`/`OFFSET`.
- Other query params on `GET /v1/boards/{board_id}/cards` (`column_id`, `sprint_id`, `archived`) apply before pagination, so `total` reflects the filtered count, not the whole board.
## Error Handling
Every non-2xx response is a JSON `ApiError`:
```json
{ "code": "NOT_FOUND", "message": "Board 079131c7-... not found" }
```
`code` is a stable, machine-readable `SCREAMING_SNAKE_CASE` string clients can branch on without parsing `message`. HTTP status is derived from `code`:
| Status | Codes |
|---|---|
| 400 | `BATCH_RESOLUTION_FAILED` |
| 404 | `NOT_FOUND`, `NOT_FOUND_BY_NAME`, `EDGE_NOT_FOUND` |
| 409 | `AMBIGUOUS`, `WIP_LIMIT_EXCEEDED`, `CONFLICT_DETECTED`, `ALREADY_EXISTS`, `UNSUPPORTED_VERSION`, `DEPENDENCY_ERROR`, `CYCLE_DETECTED`, `DUPLICATE_EDGE` |
| 422 | `VALIDATION_FAILED`, `SPRINT_BOARD_MISMATCH`, `SELF_REFERENCE` |
| 500 | `IO_ERROR`, `SERIALIZATION_ERROR`, `DATABASE_ERROR`, `INTERNAL_ERROR` |
Malformed or type-mismatched request bodies (e.g. missing a required field) also come back as `VALIDATION_FAILED` (422) in this same envelope, rather than axum's default plain-text rejection.
---
## Position in the workspace
```mermaid
graph TD
PER[kanban-persistence]
BE[kanban-backend] --> PER
BEMEM[kanban-backend-memory] --> BE
JSON[kanban-persistence-json] --> BE
SQL[kanban-persistence-sqlite] --> BE
SVC[kanban-service] --> PER
SVC --> BE
SRV[kanban-server] --> PER
SRV --> BE
SRV --> JSON
SRV --> SQL
SRV --> SVC
SRV -.->|feature: test-helpers| BEMEM
BEHTTP[kanban-backend-http] -.->|dev-dependency, feature: test-helpers| SRV
```
Solid arrows are normal (`[dependencies]`) edges; the `kanban-backend-memory`
edge is feature-gated (`test-helpers`, off by default) rather than optional
in the usual sense — it exists so integration tests can spin up an in-memory
`AppState` without touching disk. Like `kanban-cli`/`kanban-mcp`/`kanban-tui`,
`kanban-server` — not `kanban-service` — registers the concrete storage
backends (`kanban-persistence-json`, `kanban-persistence-sqlite`)
unconditionally (KAN-1027). The dashed edge from `kanban-backend-http` is a
`[dev-dependencies]` edge (feature `test-helpers`) used only to spin up a
real server for that crate's integration tests — not reachable from a
release build. See the [root README](../../README.md) for the full workspace
dependency graph.
## Dependencies
| Crate | Purpose |
|-------|---------|
| [`kanban-core`](../kanban-core/README.md) | Shared types, config |
| [`kanban-domain`](../kanban-domain/README.md) | Domain models |
| [`kanban-persistence`](../kanban-persistence/README.md) | `PersistenceStore`, `StoreRegistry` |
| [`kanban-backend`](../kanban-backend/README.md) | `KanbanBackend`, `KanbanBackendRegistry` |
| [`kanban-persistence-json`](../kanban-persistence-json/README.md) | JSON backend, registered at startup |
| [`kanban-persistence-sqlite`](../kanban-persistence-sqlite/README.md) | SQLite backend, registered at startup |
| [`kanban-service`](../kanban-service/README.md) | `KanbanContext`, all domain operations |
| [`kanban-backend-memory`](../kanban-backend-memory/README.md) (optional, feature `test-helpers`) | In-memory backend for tests |
| `axum` + `tower` + `tower-http` | HTTP routing/middleware |
| `tokio` | Async runtime |
| `serde` | Serialization |
| `prometheus` | Metrics |
| `clap` | CLI argument parsing |
| `tracing` + `tracing-subscriber` | Structured logging |
## Related crates
Used by: none in production — [kanban-backend-http](../kanban-backend-http/README.md) depends on this crate only as a dev-dependency (feature `test-helpers`) to spin up a real server for its own integration tests.