REST API (`/api/v1`)

All endpoints require authentication: a session cookie (browser) or an

API key as `Authorization: Bearer myc2-...` (machine). Browser writes

additionally require the CSRF token (header `x-csrf-token` or form

field `csrf_token`); bearer requests are CSRF-exempt.

Concepts

`GET /api/v1/concepts/{*path}`

Fetch one concept's markdown (`text/markdown`). Paths are bundle-

relative with a leading slash, e.g. `/api/v1/concepts/notes/todo.md`.

`PUT /api/v1/concepts/{*path}`

Create/replace a concept. Body: `{"markdown": "---\ntype: Note\n..."}`.

The frontmatter must parse and `type` is required. `201` on success.

`DELETE /api/v1/concepts/{*path}`

Delete a concept. `204` on success.

Graph and search

`GET /api/v1/graph`

The caller's private-bundle graph: `{"nodes": [...], "edges": [...]}`.

(Global scopes are never included — graph view is private by design.)

`GET /api/v1/search?q=...&global=1`

Search the caller's bundle; `global=1` adds the global skills shelf

and library catalogs. Results are scope-tagged:

[{"concept_path": "/my-book/book.md", "title": "My Book",
  "snippet": "...", "score": 3.0, "scope": "library"}]

`scope` is `"user"`, `"skills"`, or `"library"`. Library hits from

admin-private bookshelves are filtered out for non-admin callers.

Books and passages

`POST /api/v1/ingest` *(admin)*

Multipart book upload: fields `bookshelf` (name), `slug`, `title`,

`file` (markdown, ≤ the configured limit — default 32 MiB, admin-adjustable), plus the CSRF token. Runs the ingest

inline and returns `202`:

{"job_id": "...", "book_id": "...", "slug": "my-book",
 "status": "done", "detail": "3 catalog concepts written"}

Errors: `400` unknown bookshelf/invalid input, `409` duplicate slug or

user already has a job running.

`GET /api/v1/ingest` *(admin)*

List the 50 most recent ingest jobs.

`GET /api/v1/ingest/{id}` *(admin)*

One job's status: `{"id": "...", "status": "done", "detail": "..."}`.

`GET /api/v1/passages?resource=book://<slug>%23<anchor>`

Read a passage from the shared library stacks. Anchors:

`ch-<n>-<slug>` (chapter) or `sec-<n>-<m>-<slug>` (section); an empty

anchor returns the whole text (truncated at 128k chars). The book's

shelf must be global-read (or the caller is an admin).

Skills

`GET /api/v1/skills/{slug}/bundle`

Download a nested skill as a Claude-Code-shaped zip

(`application/zip`): `SKILL.md` (the hub concept's markdown), the

skill's companion concepts, and its manifest-declared scripts at

their manifest paths. Same visibility as the skills page — any user

may bundle a global skill; a private skill only from its own bundle.

Script payloads are md5-verified against the hub manifest during

assembly; a mismatch refuses the download rather than serving bad

bytes. The archive is deterministic — fixed entry order (`SKILL.md`,

companions sorted, scripts sorted), fixed mtime 1980-01-01, fixed

unix perms 0644, deflate — so two GETs of the same skill return

byte-identical bodies. `200` responses carry:

- `Content-Disposition: attachment; filename="<slug>-skill.zip"`

(filename characters restricted to `[A-Za-z0-9._-]`; a slug that

sanitizes to nothing falls back to `skill.zip`)

- `Cache-Control: no-store, no-cache, must-revalidate`

`404` when the slug names no nested skill — unknown slugs and legacy

flat skills answer identically (no oracle). Every other error

(traversal manifest, md5 mismatch, missing payload, key or zip

failure) is logged server-side and answered with a generic

`500 {"error": "internal error"}`.

Install into a Claude Code skills directory:

curl -fsSL -H "Authorization: Bearer myc2-..." \
  https://mycelium.example.com/api/v1/skills/pdf-to-markdown/bundle \
  -o pdf-to-markdown-skill.zip
unzip pdf-to-markdown-skill.zip -d ~/.claude/skills/pdf-to-markdown/

Chat

Two surfaces drive the librarian agent: the session-scoped SSE API

(durable, multi-turn, one in-flight turn per session) and a stateless

one-shot endpoint. Both admit through the process-global LLM gate —

see Configuration for its tunables.

Message bodies are form-encoded (`message=<text>`). The two submit

endpoints and the stateless endpoint additionally require a browser

session cookie (they are the chat UI's endpoints): a bearer-only

client gets `401`.

`GET /api/v1/chat/sessions`

The caller's sessions, newest-first by `updated_at`:

{"sessions": [{"id": "...", "title": "...", "updated_at": "..."}]}

A title that will not decrypt renders as `(unreadable)` rather than

failing the list.

`POST /api/v1/chat/sessions`

Draft create: creates the session (title derived from the message)

plus its first turn, then returns that turn's SSE stream. The

stream's first event is `session`, carrying the new id. A blank

message is `400 {"error": "empty message"}`.

`POST /api/v1/chat/sessions/{id}/messages`

Submit into an existing session; returns the turn's SSE stream. Only

one turn may run per session, so a second submit while one is running

is `409 {"error": "a turn is already running in this session"}`.

Unknown and foreign ids both answer `404 {"error": "not found"}` (no

oracle).

`GET /api/v1/chat/sessions/{id}/stream?after=<seq>`

Reattach to a session. `after` is the client's last-seen `seq`:

persisted rows after it replay, then the live tail follows until the

turn ends. An absent `after` is live-only (nothing replays). With no

turn running the stream replays and closes — poll semantics. User

rows never replay; a persisted assistant reply replays as

`assistant`, not `done`.

`GET /api/v1/chat/sessions/{id}`

Session meta (the UI's liveness poll):

{"id": "...", "title": "...", "turn_status": "running",
 "turn_resumable": false, "max_seq": 7}

`turn_status` is the latest non-abandoned turn's status (`running`,

`done`, `failed`, `interrupted`) or `null`; `turn_resumable` is true

only for an `interrupted` turn the resume worker may still claim;

`max_seq` is the transcript's highest `seq` (the `?after=` base).

`DELETE /api/v1/chat/sessions/{id}`

Delete a session and its transcript: `{"ok": true}`. `409` while a

turn is running, `404` unknown/foreign.

`POST /api/v1/chat/sessions/{id}/delete` is the native-form variant —

same rules, `303` to `/chat` on success.

Stream format

`text/event-stream` with `Cache-Control: no-store`. Each event is one

`data: <json>` line followed by a blank line. Every event carries

`type`. All but `queued` and `admitted` also carry `seq` — the

transcript row the event was persisted as, which clients use to drop

snapshot/live duplicates and as the `after` cursor when they reattach

(the draft-create `session` event has no row of its own and carries

`0`).

| `type` | Fields | Meaning |

|---|---|---|

| `session` | `session_id`, `seq` | draft-create only: the new session's id (first event) |

| `queued` | `position` | waiting for an LLM slot, at this 0-based queue position |

| `admitted` | *(none)* | a slot was granted; the run begins |

| `tool` | `name`, `detail`, `seq` | the agent invoked a tool |

| `done` | `reply`, `seq` | the live run finished with this reply |

| `assistant` | `reply`, `seq` | replay only: a persisted assistant reply |

| `error` | `error`, `seq` | the run failed with this message |

`queued` and `admitted` carry no `seq`: they are presentation, never

transcript rows, and never replay. `queued` is emitted on enqueue and

on every position change; `admitted` only for a turn that actually

queued — a turn admitted straight away emits neither. The stream ends

after `done` or `error` (or when the run's channel closes).

Capacity

Both submit endpoints check the gate's waiter count once, before they

write anything. At the configured depth (`chat_queue_depth`) the

submit is refused with

`429 {"error": "LLM at capacity — queue full"}` and `Retry-After: 5`

— no session row and no turn row are created, so a full queue never

strands rows nobody will run.

An accepted turn waits up to `chat_wait_secs` for a slot. If that

bound elapses the turn finalizes `failed` and its error row (and

`error` event) reads

`LLM at capacity — turn not started (queue full)`. A turn still

queued when the server shuts down is finalized `interrupted` with no

error row instead, and is resumed once on the next boot.

`POST /api/v1/chat`

The stateless one-shot: one message in, one reply out, no cross-call

memory. Returns `{"reply": "..."}`. Waits up to `query_wait_secs` for

an LLM slot; on timeout

`429 {"error": "LLM at capacity — try again shortly"}` with

`Retry-After: 5`. `503` is reserved for an unreachable or

unconfigured LLM backend — the two are deliberately distinct.

Dream

`POST /api/v1/dream`

Run a maintenance pass over the caller's private bundle (orphans,

broken links, likely duplicates, oversized concepts). Deterministic

signals decide whether the agent runs at all. Returns

`{"ran": true, "summary": "...", "files_changed": [...]}`, or

`{"ran": false, "reason": "..."}` when nothing needed doing. It is

wait-class on the LLM gate with the chat bound (`chat_wait_secs`); no

slot within it answers

`429 {"error": "LLM at capacity — try again shortly"}` with

`Retry-After: 5`.

Health

`GET /api/v1/health`

Detailed JSON health (authenticated). `GET /health` (public coarse

status plus the mutation-queue fields `queue_depth`,

`oldest_pending_age_seconds`, `queue_dead_count`,

`llm_last_success_seconds`, and the LLM-gate fields `llm_active`,

`llm_queued`, `llm_capacity`; it answers 503 `degraded` when the

database is unreachable, any queued item is dead, or a pending item

has waited past twice the integration deadline — the gate fields are

observability only and never make it degraded) and `GET /metrics`

(Prometheus text) live outside `/api/v1` and are public by design —

for container orchestration and scrape endpoints.

Error shape

Errors are `{"error": "..."}` with an appropriate status code.

Internal details (SQL, paths, crypto errors) are never included.