# promptlib REST API — complete reference

This document is the canonical, self-sufficient API reference. It is written so
that a human or an AI agent can build a complete frontend against the API
without reading the server source. Everything the wire does is here: every
endpoint, every request/response shape, every error, every limit.

- **Base URL**: `https://hygpo.com` (all API routes under `/v1/`)
- **Formats**: JSON request/response (`Content-Type: application/json`),
  except file upload which is `multipart/form-data`.
- **IDs**: UUIDv7 in canonical `8-4-4-4-12` form. Time-ordered, never
  sequential integers.
- **Timestamps**: RFC 3339 UTC, e.g. `"2026-07-19T15:54:26.147Z"`.
- **Errors**: every non-2xx response has the body `{"error": "<message>"}`.
  Messages are stable enough to display, but branch on the status code, not
  the text.

---

## 1. Core concepts (read this before generating UI)

**The API is the product; the frontend is yours.** The server stores *what
things are* (essence) and refuses to store *how you arrange them*
(organization) as anything but opaque JSON. Two users can render the same data
as completely different products.

- **content** — one uploaded (or, in future, generated) file: an audio track
  or an image. A content row is one **owner's projection** of a file: the same
  bytes uploaded by two users are two content rows sharing one deduplicated
  blob. A content has:
  - `caption` — the owner's one-line description (plain text).
  - `user_defined_content_meta` — the owner's **opaque JSON**. The server
    stores, returns, and size-caps it, and never reads it. Use it for tags,
    albums, layout hints — anything your frontend wants.
  - `kind` — `"image"` or `"audio"`. There are no sub-types: no "stem", no
    "vocal", no "full-mix" kind. What a track *is to you* is your frontend's
    annotation, not the content's type.
  - lifecycle: `status` × `visibility` (see §3).
- **post** — the organization/publication unit (think WordPress post). A post
  is `caption` + `body` (free text, opaque) + `meta` (opaque JSON) + an
  **ordered list of items**, each item referencing one content plus an opaque
  per-item `annotation`. The same content can appear in many posts — and
  twice in one post — with different annotations each time. Your frontend
  decides what a post *looks like* (track editor, DJ deck, gallery, article)
  — typically by convention keys you define yourself in `post.meta`.
- **generation** — content can also be *generated*, not just uploaded. You
  submit an async **job** (`POST /v1/jobs`) naming a `capability` (today
  `music.synth` and its planning/understanding siblings). A worker runs it and
  parks the result(s) in **scratch** — candidate artifacts that are NOT yet
  library contents and cost you no storage until you keep them. You poll the
  job, preview the candidates, then **commit** the ones you want, promoting them
  into permanent `content` (the prompt is written to the searchable `body`, the
  resolved generation request to server-owned metadata). Uncommitted candidates
  expire. See §4 *Async jobs* and §5.13–5.18.
- **referral** — registration is invitation-only: every new account spends a
  single-use referral code minted by an existing user.

**What does NOT exist yet** (do not generate UI that needs it):
- Generation is **audio only** today (`music.*`); image and text generation are
  designed, not yet served.
- No search endpoint (semantic prompt search is designed, not yet served).
- No account management: no password change, no logout/revoke, no profile
  edit, no user listing.
- No content delete or content status/visibility change (contents are born
  `draft`+`private` and currently stay owner-visible only; **posts** are the
  publishable unit).
- No CORS headers: browsers must be served from the same origin (or proxied).

---

## 2. Authentication

### Model

- **Session token** (bearer): opaque signed string, **valid 15 minutes**.
  Send as `Authorization: Bearer <session_token>`.
- **Refresh token**: opaque string, **single-use**. Exchange it at
  `POST /v1/sessions/refresh` for a fresh session + a NEW refresh token. Reusing
  a refresh token that was already spent revokes the whole token family
  (defense against theft) — the client must then log in again.
- **Anonymous**: requests with NO `Authorization` header are the anonymous
  reader — GET endpoints work and return only `published` + `public` rows. A
  *present but invalid* bearer is `401`, never silently anonymous.

### Recommended client loop

1. Login → store `session_token` + `refresh_token`.
2. On any `401` from an API call: call refresh once, replace both tokens,
   retry the original request once. If refresh itself fails → prompt login.
3. Never send the refresh token as a bearer; it only ever goes in the refresh
   request body.

### Roles

| role | may |
|---|---|
| (anonymous) | read published+public rows |
| `reader` | read (own + public) |
| `author` | read; write/publish **their own** content & posts; mint codes granting ≤ their rank |
| `editor` | read; write/publish **anyone's** |
| `admin` | everything, including reading all rows regardless of visibility |

---

## 3. Lifecycle: status × visibility

Two orthogonal fields on both contents and posts:

- `status`: `draft` → `pending` → `published` → `archived` / `trash`
  (workflow position).
- `visibility`: `private` | `public` (audience).

A row is **world-readable only when** `status="published"` AND
`visibility="public"`. Owners always see their own rows whatever the state.
A row the caller may not see is reported as **404 not found — never 403** (the
API refuses to confirm hidden things exist). Generate UI accordingly: treat
404 as "does not exist for this user".

For v1, only **posts** expose status/visibility editing. Contents are created
`draft`+`private` and stay that way (publish the *post* that references them).

---

## 4. Conventions

### Pagination (all list endpoints)

Keyset cursor, newest first:

```
GET /v1/posts?limit=20&after=<cursor>
→ {"items": [...], "next": "<cursor or empty>"}
```

- `limit`: default 20, max 100 (values beyond are clamped).
- `next` is an opaque cursor; pass it back as `after` for the next page.
  Empty `next` = last page. Never construct a cursor yourself; a malformed
  one is `400`.

### Rate limits

| class | applies to | rate | on excess |
|---|---|---|---|
| per-IP | every request | ~300/min, burst 60 | `429` + `Retry-After` |
| auth | login / register / refresh (per IP) | ~10/min, burst 5 | `429` |
| write | uploads, edits, post writes (per user) | ~30/min, burst 10 | `429` |

`429` bodies are uniform and reveal nothing about accounts. Honor
`Retry-After` (seconds).

### Size caps (server-enforced)

| field | cap |
|---|---|
| upload file | 64 MiB |
| JSON request body | 64 KiB |
| `caption` (content & post) | 2 KiB |
| post `body` | 32 KiB |
| `user_defined_content_meta`, post `meta`, item `annotation` | 16 KiB each |
| items per post | 256 |
| contents per user | 200 rows |
| posts per user | 500 rows |
| job `params` | 16 KiB |
| in-flight jobs per user (`queued`+`running`) | 20 |
| generation candidates | scratch; expire after 24 h (or by scratch quota) unless committed |

### Async jobs (generation)

Generation is asynchronous — a job runs on a single serial GPU worker, so it is
queued, not awaited inline. The loop:

1. `POST /v1/jobs` → `202` `{"id":...,"status":"queued"}`.
2. Poll `GET /v1/jobs/{id}` until `status` is terminal
   (`done` / `failed` / `cancelled`). Poll ~1 s; polling has no side effect.
3. On `done`, the job carries `result.candidates` — one or more scratch
   artifacts. Preview a candidate's bytes with
   `GET /v1/jobs/{id}/candidates/{k}` (§5.16).
4. `POST /v1/jobs/{id}/commit` the candidate you want → it becomes a permanent
   `content`. Uncommitted candidates expire (caps above).

Statuses: `queued` → `running` → `done` | `failed` | `cancelled`. A job is
visible only to its owner (`404` otherwise), exactly like contents and posts.

---

## 5. Endpoints

### 5.1 Register — `POST /v1/users`

Anonymous. Spends a referral code. Does NOT log in (call login next).

Request:
```json
{"referral_code": "<code>", "email": "you@example.com", "password": "min 8 chars"}
```

Responses:
- `201` → `{"id","email","role","created_at"}` — role comes from the code.
- `400` invalid/expired code, or validation (`password must be at least 8
  characters`, bad email — message is display-safe).
- `409` code already used / email already registered.
- `429` auth rate limit.

Email is lowercased server-side. Password: 8–4096 chars.

### 5.2 Login — `POST /v1/sessions`

Anonymous.

Request:
```json
{"email": "you@example.com", "password": "..."}
```

Responses:
- `200` →
```json
{"session_token":"...","refresh_token":"...",
 "user":{"id":"...","email":"...","role":"author","created_at":"..."}}
```
- `401` `{"error":"invalid credentials"}` (same for unknown email — no
  enumeration).
- `429` auth rate limit.

### 5.3 Refresh — `POST /v1/sessions/refresh`

Anonymous (the token in the body is the credential).

Request: `{"refresh_token": "..."}`
Responses:
- `200` → same shape as login (NEW session token + NEW refresh token; the old
  refresh token is dead).
- `401` invalid OR reused token (reuse also revokes the family).
- `429` auth rate limit.

### 5.4 Mint referral code — `POST /v1/referral-codes`

Authenticated. Grants a role no higher than your own.

Request: `{"granted_role": "reader" | "author" | "editor" | "admin"}`
Responses:
- `201` → `{"code":"...","granted_role":"...","expires_at":"..."}` — codes
  live 24 h, single-use.
- `403` your role may not grant that role.
- `429` more than 50 unexpired codes outstanding.

### 5.5 Upload content — `POST /v1/contents`

Authenticated (any writing role). `multipart/form-data`:

| part | value |
|---|---|
| `file` | the bytes (≤ 64 MiB) |
| `kind` | `image` or `audio` |
| `caption` | optional; >2 KiB is truncated |

Responses:
- `201` → content DTO (below). Born `status:"draft"`, `visibility:"private"`.
  **Idempotent**: re-uploading the same bytes returns the *existing* content
  (same `id`), not a new row and not an error — the API deduplicates per owner.
- `400` bad form / unsupported kind.
- `413` file too large.
- `429` write rate or the 200-row storage quota
  (`"you have reached your storage limit"`).

Content DTO:
```json
{"id":"0198...","owner_id":"0198...","kind":"audio",
 "status":"draft","visibility":"private",
 "caption":"...","original_name":"track.mp3",
 "tool_defined_content_meta":{...},        // omitted when unset — READ-ONLY
 "user_defined_content_meta":{...},        // omitted when unset
 "created_at":"...","updated_at":"..."}
```
`original_name` is the uploaded filename, display-only. The file hash is never
exposed.

`tool_defined_content_meta` are the tool-parsed *essence* facts — for a committed
generation, the resolved plan (`bpm`, `keyscale`, `duration`, `vocal_language`,
`lyrics`, `seed`, …) plus the byte `format`. It is **read-only**: the generate /
commit flow writes it; there is no request field that sets it (a plain upload
leaves it absent). Do not confuse it with `user_defined_content_meta`, which is
your own opaque organising JSON that you PATCH.

### 5.6 List contents — `GET /v1/contents`

Anonymous or authenticated. Paginated (§4). Scope: anonymous → published +
public; user → their own (all states) + published/public of others; admin →
everything.

`200` → `{"items":[<content DTO>...],"next":"..."}`

### 5.7 Get content — `GET /v1/contents/{id}`

Same scoping. `200` → content DTO. `404` unknown OR not visible to you.

#### 5.7a Download content bytes — `GET /v1/contents/{id}/file`

The raw bytes of a committed content — the read-side twin of the candidate
preview (§5.16), for content already in a library. Draw a waveform, play, or
download from here. Same scoping as §5.7 (a row not visible to you is `404`,
never "forbidden").

- `200` → the bytes. `Content-Type` reflects the file (`audio/wav`,
  `audio/mpeg`, `image/png`, …; `application/octet-stream` when unknown).
- **Cacheable & seekable**: responds with a strong `ETag` (send it back as
  `If-None-Match` for a `304`) and `Cache-Control: private, immutable` — the
  bytes never change under an id. Honours `Range`, so an `<audio>`/`<img>` can
  scrub or resume without re-fetching the whole file.
- `404` unknown OR not visible to you.

### 5.8 Edit content — `PATCH /v1/contents/{id}`

Authenticated; owner only (a non-owned id is `404`). Partial: only fields
present in the body change.

Request (any subset, at least one):
```json
{"caption": "new caption",
 "user_defined_content_meta": {"any":"json"} }
```
- `user_defined_content_meta: null` **clears** the field.
- `400` no editable fields / caption >2 KiB / meta >16 KiB.
- `200` → updated content DTO.

Status/visibility are NOT editable here (publish the post, not the content).

### 5.9 Create post — `POST /v1/posts`

Authenticated (writing role).

Request:
```json
{"caption": "My song",
 "body": "free text — the server never interprets it",
 "meta": {"layout": "track-editor"},
 "items": [
   {"content_id": "<content uuid>", "annotation": {"role":"vocal","gain":0.8}},
   {"content_id": "<content uuid>", "annotation": {"role":"full-mix"}}
 ]}
```
- All fields optional; `{}` is a valid blank draft.
- Every `content_id` must be a content **you own** (and not trashed) — else
  `400` `"one or more items is not a content you own"`. To use someone
  else's public content, upload the file yourself first (dedup makes this
  cheap server-side).
- Item order in the array IS the order (`seq` is assigned 0..n-1). The same
  content may appear multiple times.
- **Idempotency**: an identical create payload (after JSON normalization —
  key order and whitespace don't matter) returns the FIRST post again
  instead of a duplicate. Exception: the all-empty blank draft always creates
  a new post. After a post is edited once, its create-payload no longer
  collides.

Responses:
- `201` → post DTO (hydrated, below).
- `400` invalid item / too many items (>256) / field over cap.
- `429` write rate or the 500-post quota.

### 5.10 List posts — `GET /v1/posts`

Anonymous or authenticated. Paginated (§4). Same scoping rules as contents.
Returns post **summaries — no `items` field at all** (open the post to get
items):

```json
{"items":[
  {"id":"...","owner_id":"...","caption":"...","body":"...",
   "meta":{...},"status":"published","visibility":"public",
   "created_at":"...","updated_at":"..."}],
 "next":""}
```

### 5.11 Get post — `GET /v1/posts/{id}`

Anonymous or authenticated. `404` if not visible (§3).

`200` → post DTO with hydrated items:
```json
{"id":"...","owner_id":"...","caption":"...","body":"...","meta":{...},
 "status":"...","visibility":"...","created_at":"...","updated_at":"...",
 "items":[
   {"seq":0,"content_id":"...","annotation":{...},"content":{<content DTO>}},
   {"seq":1,"content":null}
 ]}
```

**Holes**: an item whose content the *viewer* may not see keeps its `seq` and
`annotation` but has `"content": null` and **no `content_id`**. This is not an
error — render the gap however you like (a locked slot, a skip). Owners always
see their own items fully hydrated. `items` is always present (empty post →
`[]`).

### 5.12 Edit post — `PATCH /v1/posts/{id}`

Authenticated; owner only (`404` otherwise). Partial; at least one field.

Request (any subset):
```json
{"caption":"...", "body":"...", "meta":{...} | null,
 "status":"draft|pending|published|archived|trash",
 "visibility":"private|public",
 "items":[{"content_id":"...","annotation":{...}}, ...]}
```
- `items`, when present, REPLACES the whole list (there is no per-item patch;
  send the full new arrangement).
- `meta: null` clears it.
- **Publishing**: sending `status:"published"` or `visibility:"public"`
  requires the publish permission (all writing roles have it today) — expect
  `403` `"your role may not publish"` if denied.
- Soft delete = `{"status":"trash"}`. There is no hard-delete endpoint.

Responses: `200` → hydrated post DTO · `400` bad field / invalid items /
empty edit · `403` publish denied · `404` not yours · `429` write rate.

### 5.13 Submit a generation job — `POST /v1/jobs`

Authenticated (writing role). See §4 for the async loop.

Request:
```json
{"capability": "music.synth",
 "op": "generate",
 "params": {"caption": "energetic pop-rock, punchy drums",
            "duration": 190, "vocal_language": "en", "batch": 4}}
```
- `capability`: **`music.synth` is served today.** `music.plan` /
  `music.understand` / `music.transcode` (and `image.*` / `text.*`) are designed
  below but **return `400` until served** — the API refuses work no production
  line implements rather than queueing it forever.
- `op` (only for `music.synth`): **`generate` is served today** (omitting `op`
  means `generate`). `cover` | `repaint` | `extract` | `lego` | `complete` are
  designed and **return `400` until served**.
- `params`: curated per capability (below). **Inputs are referenced by a content
  id you own — never raw bytes.** If the source isn't a content yet, upload it
  first (§5.5); content-addressed dedup makes that cheap.
- **Idempotency**: an identical `(capability, op, params)` returns the SAME job
  instead of burning the GPU twice.

`music.synth` params by `op`:

| op | source | key params |
|---|---|---|
| `generate` | — | `caption` (req), `lyrics`, `duration`, `bpm`, `keyscale`, `timesignature`, `vocal_language`, `seed` (0 is a valid, reproducible seed), `batch` (≤ 4; larger values are clamped, and the backend may return fewer) |
| `cover` / `cover-nofsq` *(designed, 400 today)* | `source_content_id` | `caption`, `cover_strength` |
| `repaint` *(designed, 400 today)* | `source_content_id` | `start`, `end` (seconds; negative `start` / `end` past duration = outpaint) |
| `extract` / `lego` / `complete` *(designed, 400 today)* | `source_content_id` | `track` (`vocals`/`drums`/`bass`/…) — needs a base (non-turbo) model |

`music.plan` (caption → lyrics + metadata, no audio) and `music.understand`
(`source_content_id` → metadata + lyrics) take the planning params;
`music.transcode` takes `source_content_id` + `output_format`.

Responses:
- `202` → job DTO, `status:"queued"`.
- `400` unknown capability/op, bad params, or a `source_content_id` you don't own.
- `403` your role may not generate.
- `429` generation rate limit or the 20 in-flight-job cap.

### 5.14 Poll a job — `GET /v1/jobs/{id}`

Owner only (`404` otherwise).

`200` → job DTO:
```json
{"id":"...","capability":"music.synth","op":"generate",
 "status":"done","created_at":"...","updated_at":"...",
 "result":{"candidates":[
   {"index":0,"format":"wav",
    "metadata":{"bpm":86,"keyscale":"G major","duration":190,
                "vocal_language":"fr","seed":555601209}}]}}
```
`result` is present only on `done`; `error` (display-safe) only on `failed`.
`metadata` is the *essence* the generator resolved (bpm, keyscale, duration,
lyrics, the resolved seed…) — a candidate has no fields beyond
`index`/`format`/`metadata`; duration and friends live INSIDE `metadata`.

### 5.15 List your jobs — `GET /v1/jobs`

Authenticated. Paginated (§4), newest first. Summaries omit `result`.

### 5.16 Fetch a candidate's bytes — `GET /v1/jobs/{id}/candidates/{k}`

Owner only. Returns the raw audio (`Content-Type: audio/mpeg` or `audio/wav`) of
candidate `k`, for waveform/playback preview before you commit. `404` if the job
isn't `done`, `k` is out of range, the candidate expired, or the job isn't
yours. The latent is internal and never served.

### 5.17 Commit a candidate — `POST /v1/jobs/{id}/commit`

Owner only. Promotes a scratch candidate into your permanent library.

Request: `{"candidate": 0, "caption": "optional override"}`
- Creates a `content` from candidate `k`. The bytes are already stored, so this
  is near-instant and adds no new storage bytes — only a library row. (Writing
  the prompt into the content's `body` and the resolved generation request into
  its server-owned metadata is planned; not wired yet.)
- **Idempotent**: the same bytes are one content per owner (§5.5), so committing
  the same candidate twice returns that *same* content (`201`), not an error —
  keep it again and you get the content back, ready to arrange.

Responses: `201` → content DTO · `400` job not `done` / `candidate` out of range
· `404` not yours · `429` the 200-row storage quota.

This is the durability boundary: commit what you want to keep; everything else
expires with the job.

### 5.18 Cancel a job — `POST /v1/jobs/{id}/cancel`

Owner only. Cancels a `queued` or `running` job (checked between model steps).
`200` → job DTO (`status:"cancelled"`) · `404` not yours · `409` already
terminal.

---

## 6. Error catalog (by status)

| status | meaning | typical bodies |
|---|---|---|
| 202 | generation job accepted (async) | job DTO, `status:"queued"` |
| 400 | malformed JSON, field over cap, unknown enum value, invalid referral code, invalid post item, bad cursor, unknown capability/op, source content not yours | `{"error":"caption is too large"}` |
| 401 | missing/expired/invalid credential; bad login | `{"error":"invalid credentials"}` |
| 403 | role lacks the verb (write / publish / grant) | `{"error":"your role may not publish"}` |
| 404 | not found OR not visible to you (indistinguishable by design) | `{"error":"not found"}` |
| 409 | referral code/email already used (upload & commit are idempotent, not 409) | `{"error":"code already used"}` |
| 413 | upload or JSON body over the byte cap | `{"error":"upload exceeds the size limit"}` |
| 429 | rate limit or row quota; may carry `Retry-After` | `{"error":"too many requests"}` |
| 500 | server fault (never carries internals) | `{"error":"internal error"}` |

---

## 7. Quick start (curl)

```sh
B=https://hygpo.com

# login
S=$(curl -s $B/v1/sessions -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"..."}' | jq -r .session_token)

# upload a track
CID=$(curl -s $B/v1/contents -H "Authorization: Bearer $S" \
  -F kind=audio -F caption="demo" -F file=@track.mp3 | jq -r .id)

# create a post arranging it, twice, with your own annotations
PID=$(curl -s $B/v1/posts -H "Authorization: Bearer $S" -H 'Content-Type: application/json' \
  -d '{"caption":"My song","meta":{"layout":"track-editor"},
       "items":[{"content_id":"'$CID'","annotation":{"role":"vocal"}},
                {"content_id":"'$CID'","annotation":{"role":"full-mix"}}]}' | jq -r .id)

# publish the post
curl -s -X PATCH $B/v1/posts/$PID -H "Authorization: Bearer $S" \
  -H 'Content-Type: application/json' \
  -d '{"status":"published","visibility":"public"}'

# generate a track (async): submit → poll → commit the first candidate
JID=$(curl -s $B/v1/jobs -H "Authorization: Bearer $S" -H 'Content-Type: application/json' \
  -d '{"capability":"music.synth","op":"generate",
       "params":{"caption":"energetic pop-rock","duration":180,"batch":2}}' | jq -r .id)
until [ "$(curl -s $B/v1/jobs/$JID -H "Authorization: Bearer $S" | jq -r .status)" = done ]; do sleep 1; done
GCID=$(curl -s $B/v1/jobs/$JID/commit -H "Authorization: Bearer $S" -H 'Content-Type: application/json' \
  -d '{"candidate":0,"caption":"my generated track"}' | jq -r .id)

# anyone, no auth — the public feed
curl -s $B/v1/posts | jq .
```

---

*This file is served at `/docs/api.md` and is intended for both humans and AI
agents (see `/llms.txt`). The interactive console at `/console/` exercises the
same endpoints by hand.*
