# web-slides API guide

Create Markdown slide decks, preserve their revision history, generate images, and download PDF, PowerPoint, individual-slide PNG, or all-slide PNG ZIP files through the same authenticated API used by the application.
Every resource operation described here supports a web-slides Bearer API token, including asset and export downloads, provider connection management, and token management.
Google sign-in bootstraps an account and its first token; subsequent automation does not need browser cookies.

This guide describes the implemented `/api/v1` API.
Use the deployed service's [OpenAPI schema](/openapi.json) for machine-readable request schemas.
Examples use fabricated UUIDs, synthetic timestamps, and placeholders; they do not identify real users or resources.
Catalog activation, provider capabilities, and deployment-configurable limits can change. Read their current API responses before submitting work.

## Machine-readable discovery

The public `/llms.txt` links to this guide and `/openapi.json`. `/llms-full.txt`
serves the complete current guide as plain text. `/sitemap.xml` lists only public
informational pages. `/robots.txt` discourages crawling private application paths
and shared-file URLs; these responses also carry `X-Robots-Tag: noindex`.
Crawler directives are guidance, not access control: private API routes still
require authentication, and no user documents or tokens appear in discovery files.

## Contents

- [Authentication and the first token](#authentication-and-the-first-token)
- [Conventions and errors](#conventions-and-errors)
- [Endpoint index](#endpoint-index)
- [Projects](#projects)
- [Project gallery](#project-gallery)
- [Decks](#decks)
- [Revisions and optimistic concurrency](#revisions-and-optimistic-concurrency)
- [Branches and three-way merging](#branches-and-three-way-merging)
- [Image assets and frozen references](#image-assets-and-frozen-references)
- [Shared image library](#shared-image-library)
- [Markdown and rendering profile](#markdown-and-rendering-profile)
- [Fonts](#fonts)
- [Preview](#preview)
- [Exports and authenticated downloads](#exports-and-authenticated-downloads)
- [Public PDF and PPTX permalinks](#public-pdf-and-pptx-permalinks)
- [Jobs, idempotency, and retries](#jobs-idempotency-and-retries)
- [OpenRouter connections](#openrouter-connections)
- [Model discovery and image generation](#model-discovery-and-image-generation)
- [Project issues](#project-issues)
- [Audit events](#audit-events)
- [Limits and security behavior](#limits-and-security-behavior)
- [Complete curl workflow](#complete-curl-workflow)
- [Complete Python workflow](#complete-python-workflow)
- [Troubleshooting](#troubleshooting)

## Authentication and the first token

### Two different credentials

| Credential | Purpose | Where it belongs |
| --- | --- | --- |
| web-slides token, beginning `wst_` | Authenticate to this service as the issuing user | `Authorization: Bearer <web-slides-token>` |
| OpenRouter API key | Pay for and authorize image generation upstream | The `api_key` body field when creating or replacing a connection |

Do not put an OpenRouter key in the web-slides Authorization header.
A web-slides token is not an OpenRouter credential.
The connection API encrypts the provider key and returns only masked connection metadata.
An agent uses a connection UUID for generation; generation responses never contain the provider key.

```http
GET /api/v1/me HTTP/1.1
Authorization: Bearer wst_REPLACE_WITH_YOUR_SERVICE_TOKEN
```

Use HTTPS for remote services. Plain HTTP is appropriate only for local development on loopback.
Do not put a token in a query string, Markdown, a generation prompt, a URL, or an idempotency key.
Bearer clients need no CSRF header or session cookie.
An invalid Authorization header returns 401 even if the request also contains a valid browser cookie; there is no cookie fallback.

### Bootstrap through Google sign-in

1. Visit `/auth/google/login` in a browser and complete Google sign-in.
2. The callback creates or finds your account and establishes an HttpOnly `ws_session` cookie.
3. Call `GET /api/v1/me` in that signed-in browser to obtain the session's `csrf_token`.
4. Call `POST /api/v1/tokens` with that cookie and the `X-CSRF-Token` header.
5. Save the returned `token` immediately in a private file or secret store. It is returned only once.

There is no anonymous endpoint that issues a service token, and the API does not accept a Google access token as its Bearer credential.
The OAuth callback verifies the identity before creating the application session.

The following same-origin browser JavaScript demonstrates the two API calls after sign-in:

```javascript
const identityResponse = await fetch('/api/v1/me', {credentials: 'same-origin'});
if (!identityResponse.ok) throw new Error('Sign in first');
const identity = await identityResponse.json();
const tokenResponse = await fetch('/api/v1/tokens', {
  method: 'POST',
  credentials: 'same-origin',
  headers: {'Content-Type': 'application/json', 'X-CSRF-Token': identity.csrf_token},
  body: JSON.stringify({name: 'My automation'})
});
if (!tokenResponse.ok) throw new Error('Token creation failed');
const issuedCredential = await tokenResponse.json();
// Securely save issuedCredential.token. Do not log the response or share it.
```

### Identity response

`GET /api/v1/me` returns 200:

```json
{
  "id": "11111111-1111-4111-8111-111111111111",
  "email": "example@example.invalid",
  "name": "Example User",
  "actor_type": "agent",
  "csrf_token": null
}
```

Bearer authentication sets `actor_type` to `agent`; session authentication sets it to `human` and returns a CSRF token.
In revision records, an agent's `actor_id` is the service-token UUID, not its secret value.
For human edits, `actor_id` is the user's UUID.

### Token endpoints

| Method and path | Request | Success |
| --- | --- | --- |
| `POST /api/v1/tokens` | `{"name":"My automation"}`; name is 1–100 characters | 201; token metadata plus the one-time `token` |
| `GET /api/v1/tokens` | No body | 200; metadata array, newest first, including revoked tokens |
| `DELETE /api/v1/tokens/{token_id}` | No body | 200; `{"id":"...","revoked":true}` |

Creation response, with deliberately fake credential text:

```json
{
  "id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
  "name": "My automation",
  "prefix": "wst_EXAMPLE_",
  "created_at": "2026-01-01T00:00:00Z",
  "last_used_at": null,
  "revoked_at": null,
  "token": "wst_EXAMPLE_ONLY_REPLACE_ME"
}
```

The list endpoint returns these fields except `token`.
The server stores a hash of the service token; it cannot return the original later.
There is a maximum of 30 active tokens per account; another creation returns 409.
A Bearer token can create and revoke tokens within its account, including itself.
Revocation is idempotent for an existing owned token; later authenticated requests using it return 401.

### Browser CSRF and logout

Cookie-authenticated API writes require `X-CSRF-Token` from `/api/v1/me`.
This applies to POST, PATCH, DELETE, and other methods except GET, HEAD, and OPTIONS.
When an Origin header is present, it must match the configured application origin.
Missing or invalid CSRF values and an incorrect Origin return 403.

`POST /auth/logout` is a separate browser-session endpoint: send the CSRF value as a form field named `csrf`, not as the API header.
Successful logout redirects with 303 and removes the session. It does not revoke separately issued service tokens.
The default browser-session lifetime is 24 hours, configurable by the deployment.

## Conventions and errors

### URLs, identifiers, and data types

- Prefix resource paths with the service origin, `https://webslides.parklab.work`, or your own deployment's origin.
- Resource IDs are opaque UUID strings. A revision ID, asset ID, asset-version ID, and job ID are different identifiers.
- JSON is UTF-8. Korean and other Unicode Markdown text is supported.
- Timestamps are ISO 8601 date-time strings; nullable timestamps are JSON `null` until set.
- JSON endpoints use `Content-Type: application/json`. Asset uploads use multipart form data.
- Collection responses are plain arrays, not `{items: ...}` envelopes.
- Do not send owner or actor fields to create resources; authentication determines them.
- Content request models reject unknown fields. Do not rely on other request models accepting extra fields.

The following fabricated IDs recur in examples:

| Resource | Example UUID |
| --- | --- |
| Project | `22222222-2222-4222-8222-222222222222` |
| Deck | `33333333-3333-4333-8333-333333333333` |
| Revision | `44444444-4444-4444-8444-444444444441` |
| Branch | `55555555-5555-4555-8555-555555555555` |
| Asset | `66666666-6666-4666-8666-666666666666` |
| Asset version | `77777777-7777-4777-8777-777777777777` |
| Issue | `88888888-8888-4888-8888-888888888888` |
| Job | `99999999-9999-4999-8999-999999999999` |
| OpenRouter connection | `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` |

### HTTP status codes

| Status | Meaning and client action |
| --- | --- |
| 200 | Successful read, update, archive, revoke, or binary download |
| 201 | Created a resource or appended a revision, including restore and merge |
| 202 | A job was accepted or an existing job was returned; inspect its actual status |
| 400 | Examples include failed OAuth verification or an invalid request host |
| 401 | Missing, invalid, or revoked service token; missing or expired browser session |
| 403 | Browser CSRF or Origin check failed |
| 404 | Resource missing, owned by another account, or incompatible with the requested parent resource |
| 409 | Version/revision conflict, merge conflict, incompatible idempotency key, quota conflict, inactive connection/model, unavailable snapshot, or download not ready |
| 422 | Invalid fields, unsupported options, rejected Markdown/image, or invalid asset references |
| 429 | Account request limit, pending-job limit, daily generation limit, or preview contention |
| 503 | A required service component is unavailable; see the affected endpoint |

Worker failures are different from HTTP request failures.
A job GET usually returns 200 even when its body has `"status":"failed"`.
Provider errors, missing installed fonts, insufficient worker disk space, and renderer failures are reported through `job.error` after execution.
Do not expect a provider's upstream HTTP status to become the status of the job GET response.

### Error bodies

Most request errors have a string `detail`. Conflict endpoints may use an object:

```json
{
  "detail": {
    "code": "version_conflict",
    "message": "The head changed; read the current version before retrying",
    "current_version_id": "44444444-4444-4444-8444-444444444442"
  }
}
```

Schema-validation errors use a `detail` array containing `loc`, `type`, and `msg`.
The application suppresses the submitted input from these validation responses.
Many deployed messages are Korean; do not branch application logic on translated prose.
Use the HTTP status and structured `code` when available. There is no universal machine-readable code for every error.

## Endpoint index

All paths below require an authenticated owner except the public service-information endpoints listed afterward.

| Area | Methods and paths |
| --- | --- |
| Identity | `GET /api/v1/me` |
| Tokens | `GET`, `POST /api/v1/tokens`; `DELETE /api/v1/tokens/{token_id}` |
| Projects | `GET`, `POST /api/v1/projects`; `GET`, `PATCH`, `DELETE /api/v1/projects/{project_id}` |
| Project gallery | `GET /api/v1/gallery`; `POST /api/v1/gallery/projects/{project_id}/thumbnails`; `GET /api/v1/gallery/thumbnails/{job_id}/{slide_number}` |
| Decks | `GET`, `POST /api/v1/decks`; `GET`, `PATCH`, `DELETE /api/v1/decks/{deck_id}` |
| Revisions | `GET`, `POST /api/v1/decks/{deck_id}/versions`; `GET /api/v1/decks/{deck_id}/versions/{version_id}` |
| History operations | `GET /api/v1/decks/{deck_id}/diff`; `POST /api/v1/decks/{deck_id}/restore` |
| Branches | `GET`, `POST /api/v1/decks/{deck_id}/branches`; `POST /api/v1/decks/{deck_id}/merge` |
| Assets | `GET /api/v1/assets`; `POST /api/v1/assets/upload`; `GET /api/v1/assets/{asset_id}` |
| Asset versions | `GET /api/v1/assets/{asset_id}/versions/{version_id}` and the same path plus `/download` |
| Shared image library | `GET /api/v1/library/images`, `/library/categories`, `/library/tags`; `GET /library/images/{image_id}`, `/library/images/{image_id}/versions`, `/library/images/{image_id}/versions/{version_id}` and the version path plus `/download` |
| Preview | `POST /api/v1/preview` |
| Export jobs | `POST /api/v1/decks/{deck_id}/exports`; `GET /api/v1/exports/{job_id}` and the same path plus `/download` |
| Public-link management | `POST /api/v1/exports/{job_id}/public-links`; `GET /api/v1/public-links`; `GET`, `DELETE /api/v1/public-links/{link_id}` |
| Job management | `GET /api/v1/jobs`; `POST /api/v1/jobs/{job_id}/retry` |
| Provider connections | `GET`, `POST /api/v1/connections`; `PATCH`, `DELETE /api/v1/connections/{connection_id}`; `POST /api/v1/connections/{connection_id}/verify` |
| Catalogs | `GET /api/v1/fonts`; `GET /api/v1/models`; `GET /api/v1/diagrams/catalog` |
| Generation jobs | `POST /api/v1/image-generations`; `GET /api/v1/image-generations/{job_id}` |
| Issues | `GET`, `POST /api/v1/projects/{project_id}/issues`; `GET`, `PATCH`, `DELETE /api/v1/issues/{issue_id}` |
| Audit | `GET /api/v1/audit` |

Public `GET /healthz` returns `{"status":"ok"}`.
Public `GET /readyz` returns `{"status":"ready","database":"ok"}` or a 503 not-ready response.
Readiness checks database access, not successful execution of an export or image-generation job.
Public `GET /api/v1/version` returns `service`, `version`, `git_sha`, `environment`, and `google_oauth_configured`.
The browser OAuth routes are `/auth/google/login`, `/auth/google/callback`, and `/auth/logout`.
Explicitly published PDF/PPTX files also support anonymous `GET`/`HEAD /s/{token}/download`.

## Projects

A project groups decks and provides one issue collection. It is account-owned, not a shared-team permission boundary.

### Create and read

`POST /api/v1/projects` accepts:

| Field | Required | Type and default |
| --- | --- | --- |
| `name` | Yes | Nonblank string, 1–255 characters; surrounding whitespace is trimmed |
| `description` | No | String up to 20,000 characters; default `""` |

```json
{"name":"Quarterly review","description":"Slides and preparation tasks"}
```

201 response:

```json
{
  "id":"22222222-2222-4222-8222-222222222222",
  "owner_id":"11111111-1111-4111-8111-111111111111",
  "name":"Quarterly review",
  "description":"Slides and preparation tasks",
  "archived":false,
  "created_at":"2026-01-01T00:00:00Z",
  "updated_at":"2026-01-01T00:00:00Z"
}
```

`GET /api/v1/projects` returns nonarchived projects, oldest first.
Add `?include_archived=true` to include archived records.
`GET /api/v1/projects/{project_id}` returns the same object, including when archived.

### Update and archive

`PATCH /api/v1/projects/{project_id}` accepts any of `name`, `description`, and `archived`; explicit null values are rejected.
It returns the updated project with 200. There is no project revision precondition field.
`DELETE /api/v1/projects/{project_id}` archives it and returns the project with `archived:true` and 200.
Restore its visibility with `PATCH` and `{"archived":false}`.
Archiving a project does not recursively archive its decks or issues and does not erase their history.

## Project gallery

The studio opens on a project gallery. All gallery endpoints require a session or
Bearer token and return only the current account's projects and export artifacts.

`GET /api/v1/gallery?sort=updated_desc` returns
`{"items": [...], "sort": "updated_desc", "total": 3}`. Unlike `/projects`, this
response is an object, not a bare array. Available sort keys:

| Key | Order |
| --- | --- |
| `updated_desc` (default) | Latest project or active-document edit first |
| `name_asc` | Case-insensitive project name ascending |
| `created_desc` | Most recently created project first |
| `slides_desc` | Total slides across active documents, descending |
| `slides_asc` | Total slides across active documents, ascending |

Each item includes `project_id`, `name`, `description`, `created_at`, `updated_at`,
`deck_count`, `slide_count`, `representative_deck`, and `thumbnail`. Archived
projects/documents are omitted. Ties are deterministic by name and project ID.
The representative is the most recently edited active document. Its preview shows
the cover and three evenly distributed pages through the last page. Documents
with four or fewer slides show each page once. These are positional samples,
not an AI ranking of slide importance. Empty projects have `representative_deck: null`.

`thumbnail` contains `status`, `job_id`, and `slides: [{"number": 1, "url": "..."}]`.
States are `empty`, `missing`, `queued`, `running`, `succeeded`, or `failed`.
`GET /gallery` does not start rendering. To prepare images, send
`POST /api/v1/gallery/projects/{project_id}/thumbnails` with no body; the 202 response
contains `job_id`, `status`, `deck_id`, `version_id`, and `slide_numbers`.
Poll the gallery or `GET /api/v1/exports/{job_id}` until the job succeeds.
Preparation reuses an exact-revision, default-font PNG ZIP export and the normal
worker quotas. Empty projects return 409; capacity limits return 429.
Failed jobs are not automatically retried; an explicit preparation request can
start another attempt. No paid image-generation model is called.

`GET /api/v1/gallery/thumbnails/{job_id}/{slide_number}` returns an authenticated
PNG, at most 640×480 pixels, with `X-Content-SHA256`. Foreign artifacts and nonexistent
slides return 404; unfinished or corrupt artifacts return 409. A new document
revision uses new thumbnails; old revision artifacts are never silently replaced.
The studio prepares visible missing thumbnails one job at a time and stops polling
when you leave the gallery. API clients can use the same three endpoints.

## Decks

### Create a deck

`POST /api/v1/decks` returns 201 and immediately creates the first revision, even when Markdown is empty.

| Field | Required | Type and default |
| --- | --- | --- |
| `name` | Yes | Nonblank string, 1–255 characters |
| `description` | No | String, maximum 20,000; default `""` |
| `project_id` | No | Owned project UUID or null; default null |
| `markdown` | No | String, maximum 200,000 characters; default `""` |
| `message` | No | String, maximum 10,000; default `"Initial version"` |
| `asset_versions` | No | Map of asset UUID to asset-version UUID; default null |

```json
{
  "name":"Quarterly review",
  "project_id":"22222222-2222-4222-8222-222222222222",
  "markdown":"# Quarterly review\n\nOpening slide.\n\n---\n\n# Results\n\nDetails.\n",
  "message":"Initial draft"
}
```

Deck response, with empty asset references and an abbreviated font array for readability:

```json
{
  "id":"33333333-3333-4333-8333-333333333333",
  "owner_id":"11111111-1111-4111-8111-111111111111",
  "project_id":"22222222-2222-4222-8222-222222222222",
  "name":"Quarterly review",
  "description":"",
  "current_version_id":"44444444-4444-4444-8444-444444444441",
  "archived":false,
  "created_at":"2026-01-01T00:00:00Z",
  "updated_at":"2026-01-01T00:00:00Z",
  "markdown":"# Quarterly review\n\nOpening slide.\n\n---\n\n# Results\n\nDetails.\n",
  "asset_versions":{},
  "font_versions":[],
  "renderer_version":"web-slides-v1/marp-4.4.0/mermaid-12.1.0"
}
```

Actual saved revisions contain the installed font manifest in `font_versions`; do not submit the abbreviated example as a snapshot.
`markdown`, `asset_versions`, `font_versions`, and `renderer_version` in a deck response describe its current main revision.

### List and retrieve

`GET /api/v1/decks` returns deck objects, including current Markdown, oldest first.
Use `?project_id=<project UUID>` to filter by an owned project and `?include_archived=true` to include archived decks.
`GET /api/v1/decks/{deck_id}` returns one deck, including an archived deck.
There are no page or cursor parameters on these collection endpoints.

### Update metadata or Markdown

`PATCH /api/v1/decks/{deck_id}` requires `expected_version_id`.
Optional fields are `name`, `description`, `project_id`, `archived`, `markdown`, `message`, and `asset_versions`.
The limits match creation; `message` defaults to `"Update document"`.
Omitted Markdown is copied from the current revision. Send `project_id:null` to detach a deck from a project.
Explicit null values for fields such as `name`, `description`, `markdown`, and `archived` are rejected.

```json
{
  "expected_version_id":"44444444-4444-4444-8444-444444444441",
  "name":"Quarterly review — approved",
  "message":"Rename after review"
}
```

Success returns a deck object with 200 and a new `current_version_id`.
Even a metadata-only PATCH appends a revision, so other editors' old preconditions become stale.
Deck name, description, project membership, and archive status are mutable deck metadata; revision history does not snapshot those fields.

### Archive without deleting history

```http
DELETE /api/v1/decks/33333333-3333-4333-8333-333333333333?expected_version_id=44444444-4444-4444-8444-444444444442
Authorization: Bearer wst_REPLACE_WITH_YOUR_SERVICE_TOKEN
```

The precondition is a query parameter on DELETE, not a JSON body.
Success is 200 with a deck object, `archived:true`, and another appended revision.
Unarchive with PATCH, the latest `expected_version_id`, and `{"archived":false}`.
Archive status controls default listing; historical revision reads remain available.

## Revisions and optimistic concurrency

### Append a revision

`POST /api/v1/decks/{deck_id}/versions` requires both `expected_version_id` and `markdown`.
It accepts `message` (default `""`), `branch_id` (default null), and `asset_versions` (default null).
Markdown is limited to 200,000 characters; message is limited to 10,000.
The request field is `expected_version_id`, not `parent_id`.

```json
{
  "expected_version_id":"44444444-4444-4444-8444-444444444441",
  "markdown":"# Quarterly review\n\nRevised opening.\n",
  "message":"Clarify the opening slide"
}
```

201 response:

```json
{
  "id":"44444444-4444-4444-8444-444444444442",
  "deck_id":"33333333-3333-4333-8333-333333333333",
  "parent_id":"44444444-4444-4444-8444-444444444441",
  "merge_parent_id":null,
  "markdown":"# Quarterly review\n\nRevised opening.\n",
  "message":"Clarify the opening slide",
  "actor_type":"agent",
  "actor_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
  "created_at":"2026-01-01T00:01:00Z",
  "renderer_version":"web-slides-v1/marp-4.4.0/mermaid-12.1.0",
  "asset_versions":{},
  "font_versions":[]
}
```

The example abbreviates `font_versions` as above. The response is a revision, not a deck: use its `id` as the next head.
Without `branch_id`, success advances the deck's main `current_version_id`.
With `branch_id`, it advances only that branch's `head_version_id`.
The server locks the deck and compares the relevant head before saving; a stale head returns 409 without appending a revision.
On conflict, fetch the current content, reconcile your changes, and explicitly submit against the new head.
Blindly replacing the expected ID while retaining stale Markdown can overwrite changes at the application level.

### Read the immutable history

`GET /api/v1/decks/{deck_id}/versions` returns all revision metadata, newest first, across main and branches.
Each object has the revision fields above except `markdown`.
`GET /api/v1/decks/{deck_id}/versions/{version_id}` includes Markdown.
A version UUID from another deck returns 404, even if you own that other deck.
There is no API for updating or deleting a historical revision in place.

### Diff two revisions

```http
GET /api/v1/decks/33333333-3333-4333-8333-333333333333/diff?from_version_id=44444444-4444-4444-8444-444444444441&to_version_id=44444444-4444-4444-8444-444444444442
```

Both query parameters are required. The 200 response is `{"diff":"..."}` containing unified Markdown diff text.
Diff labels are revision IDs. Missing final newlines are represented with the usual `No newline at end of file` marker.
This endpoint compares Markdown only: changes exclusively to asset-version pins or font metadata need inspection of the revision objects.

### Restore a historical snapshot

`POST /api/v1/decks/{deck_id}/restore`:

```json
{
  "expected_version_id":"44444444-4444-4444-8444-444444444442",
  "version_id":"44444444-4444-4444-8444-444444444441",
  "message":"Restore the original draft"
}
```

`message` is optional and defaults to `"Restore version"`.
Success returns a new revision with 201 and advances main; it does not move main backward to the old UUID.
Its `parent_id` is the previously current head.
The restored Markdown, asset pins, font snapshot, and renderer version come from the selected historical revision.
The new record has the restoring actor and a new timestamp. Current validation and asset ownership checks still run.
Restoring content does not restore historical deck names, descriptions, project membership, or archive status.

## Branches and three-way merging

Main is represented by `deck.current_version_id`; it is not an automatically created branch-list record.
An omitted `target_branch_id` means main. Do not invent a branch named `main` to address the main head.

### Create, list, and edit a branch

`POST /api/v1/decks/{deck_id}/branches` requires `name` and `from_version_id`:

```json
{"name":"agent-review","from_version_id":"44444444-4444-4444-8444-444444444441"}
```

201 response:

```json
{
  "id":"55555555-5555-4555-8555-555555555555",
  "deck_id":"33333333-3333-4333-8333-333333333333",
  "name":"agent-review",
  "head_version_id":"44444444-4444-4444-8444-444444444441",
  "created_at":"2026-01-01T00:02:00Z"
}
```

Names are nonblank, at most 255 characters, and unique within a deck; a duplicate returns 409.
`GET /api/v1/decks/{deck_id}/branches` returns branch objects oldest first.
There are currently no branch rename or delete endpoints.
To edit, call the normal versions POST with `branch_id` and that branch's current head:

```json
{
  "branch_id":"55555555-5555-4555-8555-555555555555",
  "expected_version_id":"44444444-4444-4444-8444-444444444441",
  "markdown":"# Quarterly review\n\nBranch proposal.\n",
  "message":"Propose a new opening"
}
```

### Merge a branch

`POST /api/v1/decks/{deck_id}/merge` accepts:

| Field | Required | Meaning |
| --- | --- | --- |
| `source_branch_id` | Yes | Owned branch within this deck |
| `target_branch_id` | No | Target branch UUID; null or omitted targets main |
| `expected_version_id` | Yes | Current target head, not the source head |
| `message` | No | Up to 10,000 characters; default `"Merge branch"` |
| `markdown` | No | Complete manually resolved Markdown, up to 200,000 characters |
| `asset_versions` | No | Explicit asset-version resolutions |

```json
{
  "source_branch_id":"55555555-5555-4555-8555-555555555555",
  "expected_version_id":"44444444-4444-4444-8444-444444444442",
  "message":"Merge reviewed changes"
}
```

The server finds a common ancestor by following both parent links and performs a three-way text merge.
Success returns a new revision with 201: `parent_id` is the old target head and `merge_parent_id` is the source head.
Only the target head advances; the source branch remains unchanged.
The same source and target branch returns 422. No common ancestor or multiple best common ancestors returns 409.
An ambiguous-base response is not an automatic recursive merge; reorganize the branch history around one shared base before retrying.

### Resolve a conflict explicitly

A conflicting merge returns 409 and does not append a revision or advance a head:

```json
{
  "detail": {
    "code":"merge_conflict",
    "markdown":"<<<<<<< target\nTarget text\n||||||| base\nOriginal text\n=======\nSource text\n>>>>>>> source\n",
    "conflict_markdown":"<<<<<<< target\nTarget text\n||||||| base\nOriginal text\n=======\nSource text\n>>>>>>> source\n",
    "asset_conflicts":{},
    "base_version_id":"44444444-4444-4444-8444-444444444441",
    "current_version_id":"44444444-4444-4444-8444-444444444442",
    "source_version_id":"44444444-4444-4444-8444-444444444443",
    "message":"Resolve Markdown and asset versions, then retry with the current head"
  }
}
```

Submit the complete resolved document, not a patch or only the conflicted lines:

```json
{
  "source_branch_id":"55555555-5555-4555-8555-555555555555",
  "expected_version_id":"44444444-4444-4444-8444-444444444442",
  "markdown":"# Quarterly review\n\nAgreed wording from both edits.\n",
  "message":"Resolve opening-slide conflict"
}
```

Unresolved conflict-marker lines are rejected with 422.
If both sides selected different versions of the same image, `asset_conflicts` maps each asset ID to `{base,target,source}` version IDs.
Resolve those entries with `asset_versions:{"<asset UUID>":"<chosen owned version UUID>"}`.
Manual Markdown alone cannot resolve an asset-version disagreement for a retained reference.
The source branch head is read at merge time; there is no `expected_source_version_id` field.
If the source branch is still changing, coordinate that activity before submitting a manual resolution.

## Image assets and frozen references

### Upload a new asset or append an image version

`POST /api/v1/assets/upload` accepts multipart form data and returns 201.

| Part | Required | Meaning |
| --- | --- | --- |
| `file` | Yes | PNG, JPEG, WebP, or the static SVG subset below |
| `name` | No | At most 200 characters; default `"Image"` |
| `asset_id` | No | Existing owned asset UUID; omit to create a new logical asset |

The example uses the authenticated `ws` curl helper defined in the complete curl workflow:

```bash
ws "$WS_BASE/api/v1/assets/upload" \
  -F 'name=Revenue chart' -F 'file=@chart.png;type=image/png'
```

To add a new version of the same asset, add `-F 'asset_id=66666666-6666-4666-8666-666666666666'`.
Do not set the multipart Content-Type manually; curl supplies its boundary.
New versions update the asset's `current_version_id`; previous versions remain available.
The `name` form field names a newly created asset; uploading a version does not rename the existing asset.

Raster images are decoded, stripped of metadata/animation, converted to RGB or RGBA, and saved as PNG.
Raster limits are 12 MiB for input and normalized output and 16,777,216 pixels (4096 × 4096).
Static SVG retains its separate 10 MiB limit below.
The returned hash describes the normalized stored PNG, not necessarily your original JPEG/WebP bytes.
Unsupported or malformed image files return 422.

### Static vector assets

Use the same upload/version/download endpoints with `file=@diagram.svg;type=image/svg+xml`.
Vectors remain SVG in storage and preview; exports render them through the same offline browser.
They use the same owner checks, storage quota, immutable versions and `asset://` references as raster assets.
The returned hash is of the normalized stored SVG, not the original upload. SVG downloads use
an `.svg` attachment filename, `nosniff` and a restrictive sandbox CSP.

This is deliberately **not general-purpose SVG support**. The accepted subset is:

- A root `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">`.
  Width and height are integers from 1 to 4096; the optional viewBox must exactly match `0 0 width height`.
- `<path>` geometry. `d` supports absolute `M`, `L`, `C`, `Z` commands and decimal numbers
  (no exponent notation), with correct coordinate counts. Coordinates must be within ±1,000,000.
- `fill` and `stroke`: six-digit hex colors or `none`. Separate `opacity`, `fill-opacity` and
  `stroke-opacity` are in 0–1. `stroke-width` is 0–4096; `stroke-miterlimit` is 1–1000.
- `fill-rule`: `nonzero` or `evenodd`; `stroke-linecap`: `butt`, `round`, `square`;
  `stroke-linejoin`: `miter`, `round`, `bevel`; `stroke-dasharray`: 1–32 numbers in 0–4096.
- At most 25,000 paths, 300,000 total command segments, and 10 MiB before and after normalization.
  Coordinates are normalized to five decimal places. Existing per-document image limits still apply.
- An optional first `<defs>` child may contain `linearGradient`, `radialGradient` and `clipPath`.
  IDs use `[A-Za-z][A-Za-z0-9_-]{0,39}`, must be unique, and must exist with the correct type.
  Only `fill="url(#gradientId)"` and clip-only `<g clip-path="url(#clipId)">` references are allowed.
- Gradients require `gradientUnits="userSpaceOnUse"`. Linear coordinates are `x1,y1,x2,y2`
  (distinct endpoints); radial coordinates are `cx,cy,r,fx,fy` with positive `r`, zero inner
  radius (`fr="0"`) and focus strictly inside the outer circle. Coordinate transforms are not supported.
  Only `spreadMethod="pad"` and `color-interpolation="sRGB"` are accepted; both are defaults.
- Each gradient has 2–2048 `<stop offset="0.5" stop-color="#123456" />` children.
  Offsets are decimal fractions in nondecreasing order from 0 to 1 (not percentages),
  including both endpoints. Equal adjacent offsets are allowed; stop opacity is not supported.
- Each `clipPath` requires `clipPathUnits="userSpaceOnUse"` and exactly one compound `<path>`
  with `d` and optional `clip-rule="nonzero"` or `"evenodd"`. Open subpaths close for clipping.
  Nested clip-only groups intersect their clip regions; they cannot carry styles or transforms.
- Additional limits: 512 definitions, 50,000 total stops, 4096 groups, group depth 8,
  and 80,000 XML elements. Clip paths count toward the same path/segment budgets.

Scripts, event handlers, external URLs, `href`/template references, CSS, unrestricted groups,
transforms, text, raster images, fonts, patterns, masks, XML declarations/DTDs/entity declarations/comments
and all unlisted attributes/elements
are rejected. Keep text editable in Markdown/HTML alongside the vector geometry. These assets are
not accepted as OpenRouter image-generation references; use a raster reference for that operation.

```xml
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
  <path d="M 20 20 L 300 20 L 300 160 L 20 160 Z" fill="#087f83" />
  <path d="M 40 90 C 100 20 220 160 280 90" fill="none" stroke="#ffffff" stroke-width="4" />
</svg>
```

An internal gradient example (upload this SVG, then reference its immutable asset version):

```xml
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180">
  <defs>
    <linearGradient id="wash" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="320" y2="0">
      <stop offset="0" stop-color="#087f83" />
      <stop offset="0.5" stop-color="#a3e4d7" />
      <stop offset="1" stop-color="#ffffff" />
    </linearGradient>
  </defs>
  <path d="M 0 0 L 320 0 L 320 180 L 0 180 Z" fill="url(#wash)" />
</svg>
```

Gradient/clip assets preserve static appearance, not editable text semantics. A glyph converted
to path outlines remains non-editable geometry and must not be reported as native text.

### Asset and asset-version responses

The upload response contains asset fields plus the created `version` object:

```json
{
  "id":"66666666-6666-4666-8666-666666666666",
  "name":"Revenue chart",
  "current_version_id":"77777777-7777-4777-8777-777777777777",
  "created_at":"2026-01-01T00:03:00Z",
  "reference":"asset://66666666-6666-4666-8666-666666666666",
  "download_url":"/api/v1/assets/66666666-6666-4666-8666-666666666666/versions/77777777-7777-4777-8777-777777777777/download",
  "version": {
    "id":"77777777-7777-4777-8777-777777777777",
    "asset_id":"66666666-6666-4666-8666-666666666666",
    "mime_type":"image/png", "size":1024, "width":96, "height":54,
    "sha256":"0000000000000000000000000000000000000000000000000000000000000000",
    "source":"upload", "prompt":null, "model_id":null, "provider":null,
    "parameters":{}, "references":[], "usage":null,
    "created_at":"2026-01-01T00:03:00Z",
    "download_url":"/api/v1/assets/66666666-6666-4666-8666-666666666666/versions/77777777-7777-4777-8777-777777777777/download"
  }
}
```

The zero hash and dimensions above are illustrative, not a downloadable fixture.
`GET /api/v1/assets` returns up to 500 assets, newest first, without nested versions.
`GET /api/v1/assets/{asset_id}` adds a `versions` array, newest first.
`GET /api/v1/assets/{asset_id}/versions/{version_id}` returns one version object.
There is no separate GET `/assets/{asset_id}/versions` collection route and no asset-deletion API.

### Reference and pin an image

```markdown
![Revenue chart](asset://66666666-6666-4666-8666-666666666666)
```

At save time, the revision freezes each referenced asset to one asset-version UUID.
For an existing reference, normal revision saves inherit the parent's pin unless you explicitly change it.
For a newly introduced reference without an override, the current asset version is selected.
Later image uploads do not silently alter old revisions or inherited pins.

Explicitly select another version in the revision request:

```json
{
  "expected_version_id":"44444444-4444-4444-8444-444444444442",
  "markdown":"# Results\n\n![Chart](asset://66666666-6666-4666-8666-666666666666)\n",
  "asset_versions": {
    "66666666-6666-4666-8666-666666666666":"77777777-7777-4777-8777-777777777777"
  }
}
```

The profile also accepts `asset://<asset UUID>@<asset-version UUID>` inline.
An inline version takes precedence when resolving that reference; keep it consistent with any explicit mapping.
One revision can use only one version of a given asset. Conflicting inline versions are rejected.
Private assets must belong to the authenticated owner. Reviewed shared-library images are
also accepted; the selected version must belong to that image and remain usable under its current policy.
Explicit mapping entries for assets absent from Markdown are rejected; removed inherited references are dropped from a new revision's snapshot.

### Download images with authentication

`GET /api/v1/assets/{asset_id}/versions/{version_id}/download` returns the stored PNG or static SVG bytes with 200.
Raster responses include `Content-Type:image/png`, `Content-Disposition:inline; filename="<version UUID>.png"`, and `X-Content-SHA256`. Static SVG versions use the SVG MIME type and attachment headers described above.
The returned relative `download_url` is an authenticated API path, not a public or signed URL.
Use the same Bearer header when downloading it, and compare the downloaded SHA-256 with both metadata and the response header.
There is no need to pass a provider key or create a cookie session to download an image.

## Shared image library

The curated library is shared across accounts, separate from private uploads. "Shared"
does not mean anonymous access: every endpoint below requires a session or Bearer token.
Account holders can search, download, and reuse approved images, but cannot create,
overwrite, or delete shared originals. Catalog maintenance is an operator-only import.
Do not hardcode the presence of a particular image: search and inspect `usable`.
The packaged dataset contains 250 country/territory flags and reusable marks for
the European Union, W3C, and Creative Commons, subject to each recorded policy.
UN, WHO, UNICEF, UNESCO, World Bank Group, and OECD entries are **metadata-only**
until authorization is available: they do not include downloadable logo files.
Country/territory labels are not a claim of recognition or statehood. Afghanistan's
packaged image is explicitly labelled as the Islamic Republic tricolour; Antarctica's
image is a territorial illustration, not an official national flag.
The complete current inventory is available through the paginated API, not a static count.

### Search and discover

```sh
ws "$WS_BASE/api/v1/library/images?q=korea&category=flags&usable=true&limit=20&offset=0"
ws "$WS_BASE/api/v1/library/categories"
ws "$WS_BASE/api/v1/library/tags"
```

`GET /api/v1/library/images` returns a **bare array**, ordered by case-insensitive
name, then stable image UUID. Use `limit` (1–200, default 50) and `offset` (0–100000,
default 0) to paginate. There is no `total` or continuation token. A shorter page
ends the result set; empty search results return `[]` with 200.

| Parameter | Meaning |
| --- | --- |
| `q` | Case-insensitive substring of name, description, aliases, category, tags, or catalog key; max 200 characters. `%` and `_` are literal, not SQL wildcards. |
| `category` | Exact category slug, max 80 characters. |
| `tag` | Exact tag slug, max 80 characters. |
| `usable` | `true`: approved images with a usable current version; `false`: unavailable or metadata-only entries; omit for both. |

Categories and tags return `[{"value":"flags","count":250}]`-shaped arrays;
counts include metadata-only entries and are not filtered by the search parameters.
Use `GET /api/v1/library/images/{image_id}` for one entry. Fields include:

- `id`, stable `key`, `name`, `description`, `category`, `tags`, `aliases`;
- `source_url`, `license`, `license_url`, `attribution` (creator or holding institution),
  `restrictions`, `rights_status`, and `reusable`;
- `current_version_id`, `usable`, `asset_uri`, `download_url`, `sha256`, `mime_type`,
  `size`, `width`, `height`, `created_at`, and `updated_at`.

Always check **`usable`**, not merely the presence of an image record. Approved
rights states are `public_domain` and `licensed`; `requires_permission` and `blocked`
are not reusable. Logos may carry trademark or endorsement restrictions even when
their image copyright permits reuse. Read the entry's source, license, attribution,
and restrictions before use. Catalog inclusion is not endorsement or blanket permission.

### Pin, download, and insert

Use the returned `asset_uri`, such as
`asset://66666666-6666-4666-8666-666666666666@77777777-7777-4777-8777-777777777777`,
directly in Markdown:

```markdown
![Approved library image](asset://66666666-6666-4666-8666-666666666666@77777777-7777-4777-8777-777777777777)
```

The existing deck creation, revision, preview, and export APIs all accept this form.
An unversioned `asset://<image UUID>` pins the current version when saving a revision.
An explicit version or `asset_versions` mapping pins that exact snapshot. Private
and shared images can be mixed in one deck; private images retain owner-only access.

`GET /api/v1/library/images/{image_id}/versions` returns an array of immutable
versions, newest first (same `limit`/`offset` bounds). Append `/{version_id}` for
version metadata, or `/{version_id}/download` for the PNG bytes. A version includes
its immutable `provenance` snapshot, hash, size, dimensions, `usable`, and reuse URLs.
Download with the same Bearer header and verify `X-Content-SHA256` against `sha256`.
No Google cookie or OpenRouter key is needed.

Content or provenance changes create a new version; old snapshots are not overwritten.
A newer catalog version does not silently replace images in old deck revisions.
If reuse rights are withdrawn, old bytes/provenance are retained but further library
downloads and rendering of those references are blocked. Already downloaded files
cannot be recalled. This policy does not retroactively rewrite deck Markdown.

Unknown IDs or mismatched image/version pairs return 404; unavailable reuse returns
403 with `detail.code: "public_image_not_reusable"`. Corrupt stored library bytes
return 503 with `detail.code: "public_image_integrity_error"`. Invalid query bounds
return 422. There is no public library mutation endpoint, and uploading a private
asset with a shared image ID does not overwrite the shared record.

## Markdown and rendering profile

Markdown is the stored source. Preview and exports are derived from that source and its frozen assets.
Read the complete `renderer_version` from responses rather than hardcoding it; suffixes identify geometry, asset, and architecture-diagram revisions.
This is a constrained profile, not arbitrary Slidev/Vue or full Marp directive execution.

Supported content includes headings, paragraphs, lists, tables, quotations, fenced code, safe images, links, fenced Mermaid diagrams, and declarative `diagrams` architecture diagrams.
Use a line containing `---` to separate slides. Separators inside code fences are not interpreted as slide boundaries.
An opening `---` starts YAML frontmatter and requires a closing `---`.

````markdown
---
title: Quarterly review
lang: en
paginate: true
theme: default
---
# Overview

Prepared with the web-slides API.

---

# Process

```mermaid
flowchart LR
  A[Draft] --> B[Review] --> C[Publish]
```
````

Allowed frontmatter keys are `title`, `description`, `author`, `lang`, `font_id`, `paginate`, `theme`, and `size`.
Values must be strings except `paginate`, which must be boolean. Only `theme: default` is supported.
Frontmatter must be a flat mapping without duplicate keys, YAML aliases, anchors, custom tags, nested mappings, or sequences.
Its size limit is 10,000 characters. The whole document is limited to 200,000 characters and 100 slides.

### Per-slide style options

Store style choices in the Markdown revision itself, using at most one reserved `slide` code fence on each slide.
The fence contains a strict, flat JSON object and is not rendered as visible code.
It participates in revision history, diffs, branches, merges and restores like the rest of the source.

````markdown
---
title: A composed slide
size: "16:9"
font_id: pretendard
---
```slide
{"layout":"canvas","background":"#F7F4ED","color":"#172B3A","padding":0,"font_size":30,"heading_size":64}
```

<h1 style="position:absolute;left:72px;top:90px;width:900px;margin:0">Editable layout</h1>
<p data-font-id="arimo" style="position:absolute;left:72px;top:270px;width:760px;margin:0">Text stays in the versioned source.</p>
````

| Style key | Allowed values |
| --- | --- |
| `layout` | `flow` or `canvas`; flow is the default |
| `background`, `color` | Three- or six-digit hexadecimal color strings, such as `#fff` or `#172B3A` |
| `padding` | Integer pixels from 0 through 160 |
| `font_size` | Integer body-text pixels from 8 through 100 |
| `heading_size` | Integer heading pixels from 12 through 160 |
| `text_align` | `left`, `center`, or `right` |
| `vertical_align` | `top`, `center`, or `bottom`; applies to flow layout |
| `font_id` | A reviewed font ID from `GET /api/v1/fonts` |

Unknown keys, duplicate keys, arrays, nested objects, non-finite numbers and invalid types are rejected before rendering.
Missing options keep the renderer defaults; options apply only to their own slide.
Canvas layout gives content a positioned container. With `padding:0`, coordinates are relative to the complete slide.
Flow layout keeps normal document order; vertical alignment positions the content group inside the slide padding.
These are web-slides profile options, not arbitrary Marp directives or executable Slidev components.

Safe inline layout properties include `position:absolute` or `relative`, `top`, `left`, `right`, `bottom` (pixel values from -1280 through 2560), known `display` values including `flex` and `grid`, bounded `gap`, `flex-direction`, `flex-wrap`, `justify-content`, `align-items`, `object-fit`, and opacity from 0 through 1.
Fixed positioning, arbitrary CSS functions, remote resources and unreviewed font IDs remain prohibited.
An element may use `data-font-id="arimo"` to select a reviewed font independently of its slide; this is not a URL or a system-font lookup.

For measured placement or reconstruction, these additional **numeric-only** properties are supported:

| Property | Accepted subset |
| --- | --- |
| `transform` | One `matrix(a,b,c,d,e,f)`; six decimal numbers, a–d within ±16 and e–f within ±4096 pixels |
| `transform-origin` | Exactly two pixel coordinates, each within ±4096; use `0px 0px` for a top-left origin |
| `clip-path` | `polygon(xpx ypx,...)` with 3–64 vertices, each coordinate within ±4096; optional first argument `evenodd` or `nonzero` |
| `overflow` | `hidden` only, for a bounded clipping container |

Values may not use scientific notation, percentages, URLs, variables, nested functions, 3D transforms,
animations, `calc()`, or arbitrary CSS. A geometry declaration is limited to 4096 characters.
Transforms affect display geometry, not document order. Clipping also clips visible text: do not
count hidden or off-canvas text as readable content. Keep an element's untransformed dimensions
explicit when mapping source coordinates. For example:

```html
<div style="position:absolute;left:100px;top:200px;width:400px;height:240px;overflow:hidden;clip-path:polygon(0px 0px,400px 20px,380px 240px,0px 240px)">
  <p style="margin:0;transform-origin:0px 0px;transform:matrix(0.98,0.2,-0.2,0.98,30,20)">Editable, rotated text</p>
</div>
```

The document-wide YAML `size` chooses `"16:9"` (default, 1280 × 720), `"4:3"` (1280 × 960), or `"16:10"` (1280 × 800).
It also accepts an exact integer-pixel canvas, for example `size: "1254x969"`
(22:17, matching a landscape US Letter page). Use a lowercase `x`, no spaces,
units, decimals, or leading zeros. Each dimension must be 320–1280 pixels and
the total area must not exceed 1,228,800 pixels (the existing 4:3 maximum).
Thus `"960x1280"` and `"1000x1000"` are valid; `"1280x990"` and
`"1280x1280"` exceed the area budget and are rejected. All slides in one document
share this canvas; choose explicit dimensions that preserve your source aspect ratio.
Preview metadata exposes `width` and `height`; all export formats preserve those dimensions and aspect ratio.
PNG pixels and PPTX slide dimensions use the exact canvas. Chromium's PDF paper-unit
rounding can differ from the nominal size by less than one CSS pixel (0.75 pt).
Do not assume every PNG is 1280 × 720 when a document declares another size.

### Links, HTML, CSS, and Mermaid restrictions

- Images must use internal `asset://` references; external image URLs and data URLs in Markdown are rejected.
- Normal links may use HTTPS URLs without embedded credentials or local `#fragments`; HTTP, JavaScript, file, and data links are rejected.
- HTTPS links are navigation links, not permission for renderer network access or remote image fetching.
- Allowed HTML elements include paragraphs, headings, lists, tables, inline emphasis, code, `div`, `span`, `a`, and `img`.
- Scripts, iframes, arbitrary attributes, event handlers, HTML comments, declarations, and processing instructions are rejected.
- `title`, `alt`, restricted dimensions, safe `href`/`src`, and a restricted inline `style` are supported where applicable.
- CSS permits selected text, spacing, size, border, alignment, and color properties; arbitrary selectors, imports, functions, and scripts are not supported.
- Mermaid blocks are limited to 20,000 characters each. Click handlers, initialization directives, embedded HTML, URL-like content, and unsupported directives are rejected.
- Passing profile validation does not guarantee a syntactically valid diagram; inspect preview `errors` and export job results.

The renderer defaults to 16:9 slides at 1280 × 720 pixels; `size` selects other supported ratios.
PDF preserves the rendered page appearance; current PPTX exports contain slide images, not individually editable text and diagram shapes.
Repeated image references count toward a 48 MiB embedded-data budget in addition to the 36 MiB total decoded asset limit.
The image map is limited to 200 entries.

### Architecture diagrams: mingrammer/diagrams

Use a top-level `diagrams` fence containing **JSON, not Python**. The service uses
[mingrammer/diagrams](https://github.com/mingrammer/diagrams) 0.25.1 and Graphviz.
This is a constrained adapter, not execution of arbitrary upstream Python examples.
`GET /api/v1/diagrams/catalog` (Bearer or session authentication) returns the
supported node kinds, limits, font/icon provenance, and the complete JSON Schema.

````markdown
# Service architecture

```diagrams
{
  "version": 1,
  "title": "Agent-first slide generation",
  "direction": "LR",
  "theme": "light",
  "groups": [{"id": "backend", "label": "web-slides"}],
  "nodes": [
    {"id": "agent", "kind": "client", "label": "Agent / 사용자"},
    {"id": "api", "kind": "service", "label": "FastAPI", "group": "backend"},
    {"id": "db", "kind": "database", "label": "PostgreSQL", "group": "backend"},
    {"id": "assets", "kind": "aws.s3", "label": "S3 assets"}
  ],
  "edges": [
    {"from": "agent", "to": "api", "label": "Bearer token"},
    {"from": "api", "to": "db", "arrow": "both"},
    {"from": "api", "to": "assets", "style": "dashed"}
  ]
}
```
````

Save this Markdown with `POST /api/v1/decks`, edit it with
`POST /api/v1/decks/{deck_id}/versions` and `expected_version_id`, then use the same
preview and export endpoints as any other deck. Pin `version_id` for reproducible
source selection. Branches, diffs, restoration, and human Markdown editing also
apply: there is no separate unversioned diagram state or external editor account.
No image-generation credits or OpenRouter key are needed for diagrams.

| Field | Supported values and bounds |
| --- | --- |
| `version` | Integer `1` (default); other versions are rejected |
| `title`, node/group/edge `label` | Up to 80 characters, including Korean; node/group labels must be nonempty; newline allowed, other controls rejected |
| `direction` | `LR` (default), `RL`, `TB`, `BT` |
| `theme` | `light` (default) or `dark`; match the surrounding slide background yourself |
| `nodes` | 1–30; each has unique `id`, `label`, optional `kind` (default `service`), optional existing `group` |
| Node kinds | `client`, `service`, `database`, `queue`, `cloud`, `aws.ec2`, `aws.lambda`, `aws.s3`, `aws.rds`, `aws.elb`, `aws.sqs` |
| `groups` | Up to 8 flat groups, each with unique `id` and `label`; nesting is not supported |
| `edges` | Up to 60, with existing `from`/`to` node IDs, optional `label`, `style: solid\|dashed`, `arrow: forward\|both\|none` |
| IDs | ASCII letter followed by up to 39 letters, digits, `_` or `-` |

At most 8 diagram blocks per document, each at most 20,000 UTF-8 bytes. Duplicate
JSON keys, unknown properties/kinds, custom Graphviz attributes, Python, imports,
custom image/font paths, and nested fences in lists/quotes are rejected. Labels
are literal text, never HTML or Graphviz code; a URL in a label is not fetched.

Graphviz runs in a separate credential-free process, with a 6-second per-diagram
wall deadline, 15-second shared diagram budget, CPU/file limits (plus a 192 MiB
address-space limit on production Linux), and a
temporary working directory. Diagram time counts toward the existing 30-second
render deadline. Each generated PNG is at most 2 MiB and 2048 × 1152 pixels;
generated assets also count toward document image budgets. Oversized/failed
renders return a safe slide-numbered error, not a successful empty diagram.

Preview, PDF, PPTX, single-slide PNG, and PNG ZIP use the same diagram PNG.
The JSON source remains editable/versioned, but diagram labels and shapes in
downloads are **raster images**, not native editable PPTX objects or PDF text.
Graphviz versions can affect layout across runtime upgrades; retain downloaded
exports for byte-exact archival copies. Diagram labels use bundled NanumGothic,
independently of slide `font_id` ([project](https://hangeul.naver.com/font),
[unmodified source and OFL license](https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/nanumgothic)).

AWS icons come from the pinned diagrams distribution and are used for architecture
illustrations under [AWS's architecture icon guidance](https://aws.amazon.com/architecture/icons/).
They are not a claim of current branding or endorsement. AWS trademarks remain
with their owners; the engine's MIT license does not relicense provider marks.
Other provider libraries are not exposed by this initial allowlist.

## Fonts

`GET /api/v1/fonts` returns the reviewed installed catalog with 200.

`GET /api/v1/fonts/{font_id}/file` returns the verified WOFF2 bytes with the same
session or Bearer authentication. The response has `Content-Type: font/woff2`
and `X-Content-SHA256`; unknown IDs return 404. The studio uses this endpoint
to show actual font samples, loading each font when its card becomes visible.
Each entry includes `id`, `name`, `family`, `version`, `sha256`, `source_url`, `license`, `license_url`, `review_status`, `weights`, and `styles`.
Local filesystem names are excluded from the response.

```json
[
  {
    "id":"pretendard", "name":"Pretendard Regular", "family":"Pretendard",
    "version":"1.3.9",
    "sha256":"0000000000000000000000000000000000000000000000000000000000000000",
    "source_url":"https://example.invalid/font-source.woff2",
    "license":"SIL Open Font License 1.1",
    "license_url":"https://example.invalid/font-license",
    "review_status":"approved", "weights":[400], "styles":["normal"]
  }
]
```

This is a schema illustration; read real hashes and source/license URLs from the catalog.
Select a font with `font_id` in preview/export options or `font_id` in Markdown frontmatter.
An explicit preview/export option takes precedence over frontmatter; otherwise the first installed catalog entry is the default.
Per-slide `font_id` and element `data-font-id` are more specific overrides.
The catalog includes 21 self-hosted OFL font families, with bundled licenses and verified bytes:

| Use | Font IDs |
| --- | --- |
| Korean sans-serif | `pretendard`, `notosanskr`, `wantedsans` |
| Korean serif | `notoserifkr`, `nanummyeongjo` |
| Latin sans-serif | `arimo`, `carlito`, `inter`, `roboto`, `opensans`, `lato`, `montserrat`, `poppins`, `sourcesans3`, `ibmplexsans` |
| Latin serif / display | `sourceserif4`, `lora`, `merriweather`, `playfairdisplay` |
| Monospace / code | `ibmplexmono`, `jetbrainsmono` |

`category`, `languages`, and `variable` describe the catalog; language lists are curated usage hints, not exhaustive glyph guarantees.
`designer` names the credited creators. `project_url` links to the official project
or publisher for background and documentation; `source_url` remains the exact
versioned font file, and `license_url` remains the license reference.
Read `weights` for actual supplied weights: Arimo supplies 400–700, while other variable families have their own ranges.
Wanted Sans (`wantedsans`) supplies its official, unmodified Korean/Latin variable WOFF2
with weights 400–1000. Select it with `font_id: wantedsans`; this does not change the default font.
Regular-only fonts use browser synthesis for bold; italic styles are synthesized because the catalog currently supplies normal faces.
Only fonts selected by the document, a slide, or an HTML element are embedded, with no runtime font CDN requests.
New revisions snapshot the installed font manifest. Restore preserves the selected historical font snapshot.
The export worker compares the job's font snapshot with the revision and checks recorded versions/hashes against installed fonts.
If a required version is unavailable, the job fails rather than silently rendering with a substituted font.
If an export explicitly selects a font introduced after the historical revision, its version and hash are pinned in the export job and its cache key too.

## Preview

`POST /api/v1/preview` is synchronous and returns 200 with HTML and rendering metadata.

| Field | Type | Behavior |
| --- | --- | --- |
| `markdown` | String or null, maximum 200,000 | If supplied, preview this unsaved text |
| `deck_id` | UUID or null | Resolve an owned deck as context |
| `version_id` | UUID or null | Select that deck's revision; requires `deck_id` |
| `asset_versions` | Object or null | Explicit asset-version pins; otherwise inherit relevant revision pins |
| `font_id` | String or null | Override the document's font selection |

Saved-revision request:

```json
{"deck_id":"33333333-3333-4333-8333-333333333333","version_id":"44444444-4444-4444-8444-444444444442"}
```

Unsaved request: `{"markdown":"# Preview\n\nUnsaved content.\n"}`.
With `deck_id` and no `version_id`, the current main revision supplies context.
Supplying both Markdown and deck context previews the supplied text with relevant inherited image pins; it does not save a revision.
Omitting both source and context previews an empty document.

```json
{
  "html":"<!doctype html><html>...rendered document...</html>",
  "slide_count":2,
  "width":1280,
  "height":720,
  "errors":[],
  "renderer_version":"web-slides-v1/marp-4.4.0/mermaid-12.1.0"
}
```

The HTML is deliberately abbreviated here. A rendering error entry can contain `slide` and `message`.
Do not treat HTTP 200 alone as a clean preview: inspect the `errors` array.
Profile errors return 422, often with a `Slide N:` message.
Only one preview runs per API process at a time; contention returns 429 with `Retry-After:2`.
Preview is not a job and has no polling endpoint or idempotency key.

## Exports and authenticated downloads

### Submit an exact revision

`POST /api/v1/decks/{deck_id}/exports` accepts:

| Field | Required | Meaning |
| --- | --- | --- |
| `format` | Yes | `"pdf"`, `"pptx"`, `"png"`, or `"png_zip"` |
| `version_id` | No | Owned revision of this deck; omitted/null resolves current main at submission |
| `font_id` | No | Installed catalog font ID override |
| `slide_number` | Only for PNG | Strict integer, one-based; required for `png`, omit entirely for every other format |

```http
POST /api/v1/decks/33333333-3333-4333-8333-333333333333/exports
Authorization: Bearer wst_REPLACE_WITH_YOUR_SERVICE_TOKEN
Content-Type: application/json
Idempotency-Key: review-pdf-revision-2

{"version_id":"44444444-4444-4444-8444-444444444442","format":"pdf"}
```

202 response:

```json
{
  "id":"99999999-9999-4999-8999-999999999999", "kind":"export", "status":"queued",
  "error":null, "attempts":0, "timeout_seconds":300,
  "created_at":"2026-01-01T00:04:00Z", "started_at":null, "finished_at":null, "result":null
}
```

An existing cached job may instead be returned as `queued`, `running`, or `succeeded`, still with HTTP 202.
The job snapshots the resolved revision UUID, format, font option, renderer version, recorded font manifest, and the selected slide number for PNG.
Subsequent edits to the deck do not change the queued export's revision.
An unavailable renderer version is rejected with 409 at submission; required font snapshots are checked during execution.

### Download one slide as PNG

Use `format:"png"` with the slide's one-based position in the selected revision:

```json
{
  "version_id":"44444444-4444-4444-8444-444444444442",
  "format":"png",
  "slide_number":1
}
```

Slide 1 is the first slide, not slide 0. A missing number, zero, negative number, noninteger, or number beyond that revision's slide count returns 422 before enqueueing.
Send a JSON integer, not `"1"`, `1.0`, or `true`.
For `pdf`, `pptx`, and `png_zip`, omit `slide_number` entirely; even explicitly supplying null is rejected.
Changing the slide number changes the export snapshot and idempotency payload.
Use a separate key per revision, format, and selected slide.

```bash
JOB=$(jq -nc --arg v "$VERSION_ID" '{version_id:$v,format:"png",slide_number:1}' \
  | ws "$WS_BASE/api/v1/decks/$DECK_ID/exports" \
      -H "Idempotency-Key: slide-$VERSION_ID-001-png" --json @-)
JOB_ID=$(jq -r '.id' <<<"$JOB")
# Poll GET /api/v1/exports/$JOB_ID until status is succeeded, as shown below.
# Only then download the completed image:
ws "$WS_BASE/api/v1/exports/$JOB_ID/download" --output slide-001.png
```

The asynchronous job, polling URL, ownership checks, and retry rules are the same as PDF/PPTX.
Successful download returns `Content-Type:image/png`, PNG bytes, and the SHA-256 header.
For the standard 16:9 canvas, the image is 1280 × 720 pixels.
The attachment filename is `webslides-<revision UUID>-slide-001.png` for slide 1.
A slide PNG is an export artifact, not a newly created image-library asset; upload it separately if you want to reference it in another deck.

### Download all slides as a PNG ZIP

```json
{"version_id":"44444444-4444-4444-8444-444444444442","format":"png_zip"}
```

This enqueues one export job for all slides, not one job per slide.
After polling to success, download through the same authenticated export-download endpoint.
The MIME type is `application/zip`, and the attachment is `webslides-<revision UUID>.zip`.
Archive entries are ordered and named `slide-001.png`, `slide-002.png`, and so on.
Their images use the same rendered slide appearance and dimensions as individual PNG exports.
ZIP and PPTX both start with ZIP signatures; distinguish them by the requested format and archive contents, not the signature alone.
Verify the archive's SHA-256 against `job.result.sha256` before extracting, and inspect entries without treating them as executable files.
The ZIP's `result.size` and hash describe the complete archive, not an individual image.

### Poll until a terminal status

`GET /api/v1/exports/{job_id}` returns the job with 200.
Poll `queued` and `running`, stop on `succeeded` or `failed`, and give the client its own deadline.
Two-second polling is reasonable for ordinary use; honor 429 and `Retry-After` if encountered.
The reference acceptance CLI uses a 180-second client polling deadline; that is not the server's default 300-second job execution limit.

Successful response:

```json
{
  "id":"99999999-9999-4999-8999-999999999999", "kind":"export", "status":"succeeded",
  "error":null, "attempts":1, "timeout_seconds":300,
  "created_at":"2026-01-01T00:04:00Z", "started_at":"2026-01-01T00:04:02Z",
  "finished_at":"2026-01-01T00:04:10Z",
  "result": {
    "sha256":"0000000000000000000000000000000000000000000000000000000000000000",
    "size":12345, "mime_type":"application/pdf"
  },
  "download_url":"/api/v1/exports/99999999-9999-4999-8999-999999999999/download"
}
```

The hash and size are illustrative. A failed job has `status:"failed"`, an `error` string, and no usable download.
There are no webhooks, cancellation endpoints, or job-deletion endpoints in this API.

### Download and verify

```bash
ws "$WS_BASE/api/v1/exports/$JOB_ID/download" \
  --dump-header export.headers --output slides.pdf
```

Use the authenticated helper; do not paste the relative URL into an unauthenticated downloader.
The download endpoint checks owner and successful status again; premature downloads return 409.
The response includes `Content-Disposition:attachment`, a revision-based filename, and `X-Content-SHA256`.
PDF MIME type is `application/pdf`.
PPTX MIME type is `application/vnd.openxmlformats-officedocument.presentationml.presentation`.
Individual-slide PNG uses `image/png`; all-slide PNG ZIP uses `application/zip`.
Compare SHA-256 of the downloaded bytes with `result.sha256` and the response header; check the recorded byte size as well.
PDF begins with `%PDF-`; PPTX is a ZIP containing `[Content_Types].xml`, `ppt/presentation.xml`, slide XML, and slide images under `ppt/media/`.
Export files are limited to 80 MiB. Keep authentication on downloads and do not forward it to another origin or follow unexpected redirects.

## Public PDF and PPTX permalinks

Exports are private by default. Only the owner may explicitly publish a **completed
PDF or PPTX**. Publishing exposes the entire exported file, including embedded slide
images and any links or text in that file. It does not publish source Markdown,
individual asset URLs, project metadata, or other export formats.

**Anyone with the public URL can download and redistribute the file without an
account.** Obtain the user's permission before publishing private content, and check
that embedded material may be shared. Revocation stops future downloads from this
service; it cannot recall already downloaded copies. Do not put API tokens in these URLs.

### Create or retrieve the active link for an export

After polling a PDF/PPTX export until `succeeded`:

```bash
ws "$WS_BASE/api/v1/exports/$JOB_ID/public-links" -X POST \
  --data '{"acknowledge_public":true}'
```

The JSON boolean `acknowledge_public` is required and must be exactly `true`;
omitting it or sending a string, number, or `false` returns 422. The 201 response has:

| Field | Meaning |
| --- | --- |
| `id` | Management UUID; use this to get or revoke the link |
| `export_id` | Successful export job UUID |
| `version_id` | Immutable document revision used for this export |
| `format` | `pdf` or `pptx` |
| `sha256`, `size` | Exact published file hash and byte count |
| `download_url` | Absolute public download URL; `null` after revocation |
| `created_at`, `revoked_at` | Creation timestamp and nullable revocation timestamp |
| `warning` | Public-sharing and downloaded-copy warning |

Repeating the request while an active link exists returns the same link, including
under concurrent requests. This does not require an Idempotency-Key. A new request
after revocation creates a different URL and never reactivates the old URL. There is
no automatic expiration: revoke links that should no longer be publicly accessible.

The link pins the exact result hash, file format and revision at publication time.
Subsequent deck edits, restores, branches, new exports or changes to default fonts
do not change its bytes. Archiving a deck or revoking an API token does **not** revoke
its already published files: explicitly revoke the public links too.

### List, inspect and revoke

```bash
ws "$WS_BASE/api/v1/public-links?export_id=$JOB_ID&limit=50&offset=0"
ws "$WS_BASE/api/v1/public-links/$LINK_ID"
ws "$WS_BASE/api/v1/public-links/$LINK_ID" -X DELETE
```

List returns a bare JSON array including active and revoked links, newest first.
The optional `export_id` filter must identify an export owned by the caller. `limit`
is 1–200 (default 50); `offset` is 0–100000. DELETE returns 204, including repeated
revocations by the owner. There is no update, un-revoke, or public list endpoint.
Creation and revocation are recorded in the owner's audit trail without the public URL.

### Anonymous download

`download_url` has the shape `https://webslides.parklab.work/s/<random-256-bit-token>/download`.
GET downloads it; HEAD returns the same file metadata without a body. No cookie or
Authorization header is needed. Verify `X-Content-SHA256` and `Content-Length` against
the creation response. Filenames are `webslides-<version_id>.pdf` or `.pptx`, not user
titles. Responses use the matching PDF/PPTX Content-Type, `Content-Disposition:attachment`,
`X-Content-Type-Options:nosniff`, `Cache-Control:no-store`, `Referrer-Policy:no-referrer`
and `X-Robots-Tag:noindex, nofollow, nosnippet`. `/robots.txt` disallows `/s/`; public
links are never added to the sitemap. These directives discourage indexing but do not
make a shared URL confidential. Do not put URLs in public logs or analytics.

Unknown, malformed and revoked public URLs all return 404. A download that already
started before revocation may finish. Missing or corrupt stored files return 503
without exposing storage paths. The original `/api/v1/exports/{job_id}/download`
still requires the owner's authentication even after publication.

Creation rejects incomplete/failed exports with 409 and PNG/PNG ZIP with 422.
Generation jobs, nonexistent jobs and other owners' jobs/links return 404.
Management operations require authentication (401 otherwise) and session writes
also require the normal CSRF checks. Public downloads do not expose a metadata API.

## Jobs, idempotency, and retries

### Shared job fields

| Field | Meaning |
| --- | --- |
| `id` | Job UUID |
| `kind` | `export` or `generation` |
| `status` | `queued`, `running`, `succeeded`, or `failed` |
| `error` | Sanitized failure description or null; not an upstream response dump |
| `attempts` | Number of execution starts; starts at zero while queued |
| `timeout_seconds` | Per-job execution deadline recorded at creation |
| `created_at`, `started_at`, `finished_at` | Lifecycle timestamps; the latter two may be null |
| `result` | Kind-specific output object or null |
| `download_url` | Present for a successful export |
| `asset_id`, `asset_version_id`, `usage` | Also exposed at the top level for a generation result |

`GET /api/v1/jobs` returns up to 100 of your jobs, newest first, across both kinds.
There is no generic GET `/jobs/{id}`; use `/exports/{id}` or `/image-generations/{id}`.
Use those direct paths when a job falls outside the latest-100 list.

### Idempotency is tied to the resolved request

Supply `Idempotency-Key` on export or image-generation POST requests.
It is optional, must be nonblank, and must not exceed 128 characters.
Keys are scoped by account and job kind, not by deck or API token.
Within that scope, the same key and same normalized payload return the existing job; different payloads return 409.
The original job can be returned even if it failed. A replay is not an execution retry.

For exports, the normalized payload contains the resolved revision UUID, format, requested font override, renderer version, and recorded fonts; PNG additionally contains `slide_number`.
If you omit `version_id`, a later replay after editing main can resolve to a different revision and return 409.
For stable retries, capture and send an explicit revision UUID from the beginning.
Semantically similar options may still differ: omitted/null `font_id` and an explicit default font ID are different payload values.

For generation, the normalized payload includes validated request fields, resolved upstream model/provider IDs, the model manifest version, and effective default quality.
Reference-image UUID order is preserved in the request payload.
A catalog/default change can therefore change the payload associated with an otherwise similar request.
Connection and model validation run before the idempotency lookup; a now-inactive model or connection can prevent replay through the POST route.
Keep the job UUID and use GET to recover its status independently of those submission-time conditions.

Export submissions also reuse an owned queued, running, or successful job with the same payload, even with no idempotency key.
A new key used on such a cache hit is bound to the existing job and cannot later be reused for a different payload.
Failed exports are excluded from this automatic cache reuse, unless an explicit key refers to that failed job.
Generation has no payload-only deduplication: a fresh key or no key can create another billable request.
Do not send an idempotency key expecting deduplication on ordinary project, deck, revision, issue, connection, or upload endpoints.

### Retry policy

`POST /api/v1/jobs/{job_id}/retry` takes no JSON body and returns 202 for an eligible failed export.
It preserves the job UUID and payload, changes status back to `queued`, and clears the error and finish time.
The original `started_at` can remain until execution starts again; status is the authoritative lifecycle field.
The endpoint rejects nonfailed jobs, generation jobs, and exports already started twice with 409.
Retries also obey the account's pending-job limit and can return 429.

Image-generation jobs are not automatically retried and cannot use this retry endpoint.
A timeout or interrupted upstream connection does not prove that the provider did not charge or generate an image.
Inspect the existing job and the provider account before deliberately submitting a new request with a new key.
Workers mark timed-out or interrupted jobs failed; polling a permanently queued job usually means the worker is unavailable or busy.

## OpenRouter connections

Connection operations use your web-slides Bearer token. The provider key is a separate request-body value.
There is no public endpoint to retrieve or decrypt a stored provider key.

| Method and path | Request fields | Success |
| --- | --- | --- |
| `POST /api/v1/connections` | Required `name` (1–100), `api_key` (16–256) | 201 connection metadata |
| `GET /api/v1/connections` | No body | 200 array, newest first, excluding deleted connections |
| `PATCH /api/v1/connections/{connection_id}` | Optional `name`, `api_key`; nonnull values replace those fields | 200 connection metadata |
| `POST /api/v1/connections/{connection_id}/verify` | No body | 200 connection metadata after revalidation |
| `DELETE /api/v1/connections/{connection_id}` | No body | 200 `{"id":"...","deleted":true}` |

There is no individual GET `/connections/{id}` route; find the connection in the list.
Register a key with this request shape, replacing the placeholder privately:

```json
{"name":"My OpenRouter account","api_key":"REPLACE_WITH_OPENROUTER_KEY"}
```

Creation and key replacement perform a verification request and store one of three statuses:

| Status | Meaning |
| --- | --- |
| `active` | The provider key check succeeded; required for generation submission and execution |
| `invalid` | Provider rejected the key |
| `unavailable` | Verification did not complete successfully; retry verification later |

```json
{
  "id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "provider":"openrouter",
  "name":"My OpenRouter account", "masked_key":"••••1234", "status":"active",
  "verified_at":"2026-01-01T00:05:00Z", "created_at":"2026-01-01T00:05:00Z"
}
```

An HTTP 201 or 200 does not itself mean the connection is active; inspect `status`.
Replacing only `name` does not reverify the key. Replacing `api_key` encrypts and verifies the replacement.
Deletion clears the encrypted key and removes the connection from ordinary lookup; later mutation attempts return 404.
Queued generation jobs resolve the connection again at execution, so deletion/invalidation can make them fail and key replacement can affect which credential they use.
Provider requests use the fixed OpenRouter HTTPS origin and do not follow redirects. A custom provider base URL is not a request option.

## Model discovery and image generation

### Read the live catalog before choosing a model

`GET /api/v1/models` returns all registered entries, including inactive entries.
Select an entry whose `active` value is true and whose capabilities satisfy your request.
Do not infer activation from the model's name, a documentation example, or its presence in the response.
An inactive registered model returns 409 on generation; an unknown model key returns 422.

The registry uses service keys such as `quality`, `balanced`, and `efficient`; send `model_key`, not an arbitrary upstream model ID.
Each entry includes `key`, `model_id`, `name`, `description`, `provider`, `active`, `status`, `catalog_checked_at`, `validated_at`, `validation_result`, `capabilities`, `default_quality`, `pricing`, and `manifest_version`.
These values describe the deployed registry and can change independently of this guide.
Prices are catalog metadata, not a guarantee of the eventual charge. Actual usage is stored only when supplied upstream.

Capabilities object, illustrating the shape rather than promising any model's current configuration:

```json
{
  "input_modalities":["text","image"], "output_modalities":["image"],
  "aspect_ratios":["1:1","16:9"], "resolutions":["1K","2K"],
  "output_formats":["png"], "max_references":14, "seed":true, "quality":[]
}
```

Use `aspect_ratios`, `resolutions`, `output_formats`, and `quality` as allowed-value arrays.
An empty optional-option array means you should omit that option, not invent a value.
If `seed` is false, omit it. Limit reference images to `max_references` and the API's absolute maximum of 16.
An omitted quality uses `default_quality`; do not assume the default is the same across models.

### Submit a generation job

`POST /api/v1/image-generations` returns a job with 202. It may incur a provider charge when executed.

| Field | Required | Type, default, and validation |
| --- | --- | --- |
| `connection_id` | Yes | Owned active connection UUID |
| `model_key` | Yes | Registry key, maximum 30 characters, active entry required |
| `prompt` | Yes | String, 1–6,000 characters |
| `aspect_ratio` | No | Default `"16:9"`; must be supported by the selected model |
| `resolution` | No | Null/omitted, or a value in the model's `resolutions` |
| `output_format` | No | Default `"png"`; must be in the model's output formats |
| `quality` | No | Null/omitted uses model default; explicit value must be supported |
| `seed` | No | Integer 0–2,147,483,647; only for a model that supports it |
| `asset_id` | No | Owned existing asset UUID; appends a version instead of creating a new asset |
| `reference_version_ids` | No | Array of owned asset-version UUIDs; default `[]`, maximum 16 and model-specific cap |

Example request, valid only when the chosen live model entry supports these options:

```http
POST /api/v1/image-generations
Authorization: Bearer wst_REPLACE_WITH_YOUR_SERVICE_TOKEN
Content-Type: application/json
Idempotency-Key: diagram-variation-001

{
  "connection_id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
  "model_key":"efficient", "prompt":"A clean abstract process illustration, without text",
  "aspect_ratio":"16:9", "resolution":"1K", "output_format":"png",
  "reference_version_ids":["77777777-7777-4777-8777-777777777777"]
}
```

The service fixes generation to one output image per job and pins the upstream provider without fallback.
There is no request field for arbitrary provider options, a custom endpoint, model overrides, or an output count.
The stored image is normalized PNG even if the provider's original image used another raster encoding.

### Reference images and regeneration

`reference_version_ids` contains asset-version IDs, not asset IDs or external URLs.
Upload reference images first, retain their version IDs, then submit them in the generation request.
The worker reads those exact owned versions; later uploads to the same logical asset do not change the reference inputs.
The total reference-image size must not exceed 30 MiB; model count limits also apply.
Setting `asset_id` creates a new immutable version on that target asset. Existing deck revisions retain their existing pins.
To show the new result in a deck, append a deck revision with the returned asset version explicitly selected.

### Poll and inspect the result

`GET /api/v1/image-generations/{job_id}` returns the same job envelope as exports, with `kind:"generation"`.
A successful job's `result` has `asset_id`, `asset_version_id`, and `usage`; these fields are also repeated at the top level.

```json
{
  "id":"99999999-9999-4999-8999-999999999999", "kind":"generation", "status":"succeeded",
  "error":null, "attempts":1, "timeout_seconds":300,
  "created_at":"2026-01-01T00:06:00Z", "started_at":"2026-01-01T00:06:02Z",
  "finished_at":"2026-01-01T00:06:30Z",
  "result": {
    "asset_id":"66666666-6666-4666-8666-666666666666",
    "asset_version_id":"77777777-7777-4777-8777-777777777777", "usage":null
  },
  "asset_id":"66666666-6666-4666-8666-666666666666",
  "asset_version_id":"77777777-7777-4777-8777-777777777777", "usage":null
}
```

Fetch the resulting asset version for its authenticated `download_url`, dimensions, MIME type, byte size, and SHA-256.
Generation metadata includes `source:"generation"`, the original prompt, resolved model/provider IDs, reference-version IDs, and request parameters.
Recorded parameters include `aspect_ratio`, `resolution`, `output_format`, effective `quality`, `seed`, and `manifest_version`.
Allowed usage keys are `prompt_tokens`, `completion_tokens`, `total_tokens`, and `cost`, restricted to finite nonnegative numeric values.
Usage can be null or an empty object. Missing cost is unknown, not zero.

Provider key rejection, insufficient provider balance, provider rate limits, malformed image responses, storage exhaustion, and deadlines can make a job fail.
Failure messages are sanitized and do not contain raw provider responses or keys.
An active connection check does not guarantee balance, model availability, or a successful generation.
An active, empirically validated catalog model can still be blocked by your provider account's privacy or endpoint restrictions.
For example, OpenRouter may return an upstream 404 when no endpoint satisfies an account's Zero Data Retention (ZDR) policy and the pinned provider selection.
The worker translates that condition into a sanitized conflict-style failure message in `job.error`; polling still returns HTTP 200 with `status:"failed"`, not a raw upstream 404.
The service does not automatically relax privacy settings or switch to another provider to make the request succeed.
Check the current catalog, connection, provider-account restrictions, and sanitized error before deciding whether another permitted request is appropriate.

## Project issues

Each project has one issue collection; there is no separate board-creation endpoint.
Issues use an integer optimistic-concurrency counter, not deck revision UUIDs.

| Method and path | Request or result |
| --- | --- |
| `POST /api/v1/projects/{project_id}/issues` | Create; 201 issue object |
| `GET /api/v1/projects/{project_id}/issues` | 200 array, oldest first; `include_archived=true` includes archived issues |
| `GET /api/v1/issues/{issue_id}` | 200 issue object, including archived issues |
| `PATCH /api/v1/issues/{issue_id}` | Update with required `expected_revision`; 200 issue object |
| `DELETE /api/v1/issues/{issue_id}?expected_revision=1` | Archive with query precondition; 200 issue object |

Create fields: required nonblank `title` (1–255 characters); optional `body` (maximum 100,000, default `""`), `status` (default `todo`), and `priority` (default `normal`).
Statuses are `todo`, `in_progress`, and `done`; priorities are `low`, `normal`, and `high`.

```json
{
  "id":"88888888-8888-4888-8888-888888888888",
  "owner_id":"11111111-1111-4111-8111-111111111111",
  "project_id":"22222222-2222-4222-8222-222222222222",
  "title":"Review the slides", "body":"Check the figures and source references.",
  "status":"todo", "priority":"normal", "revision":1, "archived":false,
  "created_at":"2026-01-01T00:07:00Z", "updated_at":"2026-01-01T00:07:00Z"
}
```

PATCH example: `{"expected_revision":1,"status":"in_progress","priority":"high"}`.
PATCH accepts `title`, `body`, `status`, `priority`, and `archived`; explicitly null updated fields are rejected.
Successful updates and archives increment `revision`. A stale counter returns 409 with `{"detail":{"code":"issue_conflict","current_revision":2}}`.
Unarchive with the latest counter and `archived:false`.
There is no issue-move field or separate historical issue-version endpoint; the counter prevents stale writes and audit records track operations.

## Audit events

`GET /api/v1/audit?limit=100` returns an owner-scoped array, newest first.
The default limit is 100; supplied values are clamped to 1–200.

```json
[
  {
    "id":"cccccccc-cccc-4ccc-8ccc-cccccccccccc", "action":"deck.version_created",
    "actor_type":"agent", "actor_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
    "resource_id":"44444444-4444-4444-8444-444444444442",
    "details":{"deck_id":"33333333-3333-4333-8333-333333333333","parent_id":"44444444-4444-4444-8444-444444444441","merge_parent_id":null},
    "created_at":"2026-01-01T00:01:00Z"
  }
]
```

Common actions include `token.created`, `token.used`, `token.revoked`, `project.created`, `project.updated`, `project.archived`, `deck.created`, `deck.updated`, `deck.version_created`, `deck.restored`, `branch.created`, `branch.merged`, `issue.created`, `issue.updated`, and `asset.version_created`.
Connection events use `connection.created`, `connection.updated`, `connection.verified`, and `connection.deleted`.
Jobs record `export.queued`, `generation.queued`, kind-specific `succeeded`/`failed` events, and `export.retry`.
Worker events use `actor_type:"worker"` and the job UUID as actor ID.
Token-use audit and rate accounting are committed before the resource operation, so a rejected operation can still have a token-use event.
The audit feed is a bounded metadata view, not a copy of document text, a credential store, or a guarantee of a complete execution trace for interrupted processes.

## Limits and security behavior

| Limit | Current implementation or default |
| --- | --- |
| Authenticated request rate | 300 requests per fixed minute per user, shared by tokens and sessions |
| Request-rate response | 429 and `Retry-After:60`; polling and downloads count too |
| Active service tokens | 30 per account |
| Pending jobs | Default 5 queued/running jobs per user across both kinds; deployment configurable |
| New generation jobs | Default 50 created in the preceding 24 hours; deployment configurable |
| Pending-job response | 429 and `Retry-After:30`; daily generation limit also returns 429 |
| Worker execution timeout | Default 300 seconds, recorded in each job; deployment configurable |
| Renderer deadlines | 30 seconds for document rendering; 90 seconds for the export renderer path |
| Image size | Raster: 12 MiB input and normalized output; 16,777,216 pixels (4096²). Static SVG: 10 MiB |
| Stored asset budget | 500 MiB per account across asset versions, including active generation reservations |
| Generation reservation | 12 MiB for each queued/running generation; checked before submission/execution and released by leaving those states |
| Worker disk preflight | At least 24 MiB free before a paid image request |
| Referenced image bytes | 36 MiB per rendered document; generation reference sets remain limited to 30 MiB |
| Repeated embedded image data | 48 MiB renderer budget |
| Export result | 80 MiB maximum |
| Markdown and slides | 200,000 characters; 100 slides |
| Collection caps | Assets: 500; jobs: 100; audit: up to 200 |

Generation quota counts created jobs, including failed ones; reusing an existing idempotent job does not create another daily-quota entry.
Queued/running image reservations also reduce space available for uploads. Historical image versions consume storage even when no current deck references them.
The worker excludes its own reservation when storing the completed image and checks actual normalized output size.
These checks avoid starting known-unwritable work; an upstream charge still cannot be made transactionally atomic with local storage.

Account-owned resource lookups are private to the issuing user. A known foreign UUID does not grant access.
Exceptions are the authenticated shared-image library and explicitly published PDF/PPTX files.
Projects do not grant permissions to other accounts, and tokens have account-level access rather than per-deck scopes.
Deleted/revoked credentials and archived content have different semantics: archive preserves access and history; credential revocation prevents future authenticated use.
Renderer inputs cannot execute arbitrary JavaScript or fetch external image resources.
API binary downloads require authentication on every request; only explicitly created `/s/` permalinks allow anonymous downloads.
Responses use `Cache-Control:no-store`; do not build a public asset cache from private download URLs.

## Complete curl workflow

This runnable Bash example needs a recent curl, jq, an already issued token in a private file, and a small local `chart.png`.
It creates and retains a project, a deck with a pinned image, and a completed issue; it exports and downloads all four formats.
It does not create credentials, change provider connections, or request paid image generation.
Set the origin and token-file path for your environment. Do not enable shell tracing while handling credentials.

```bash
set -euo pipefail
set +x
WS_BASE='https://webslides.parklab.work'
WS_TOKEN_FILE='/private/path/web-slides-token'
chmod 600 "$WS_TOKEN_FILE"

ws() {
  curl --silent --show-error --fail-with-body --connect-timeout 10 --max-time 60 \
    --header @<(printf 'Authorization: Bearer %s\n' "$(tr -d '\r\n' < "$WS_TOKEN_FILE")") "$@"
}

ws "$WS_BASE/api/v1/me" | jq '{id,actor_type}'
PROJECT=$(ws "$WS_BASE/api/v1/projects" --json '{"name":"API tutorial"}')
PROJECT_ID=$(jq -r '.id' <<<"$PROJECT")
ASSET=$(ws "$WS_BASE/api/v1/assets/upload" -F 'name=Tutorial chart' -F 'file=@chart.png;type=image/png')
ASSET_ID=$(jq -r '.id' <<<"$ASSET")
ASSET_VERSION=$(jq -r '.current_version_id' <<<"$ASSET")
DECK=$(jq -nc --arg p "$PROJECT_ID" --arg a "$ASSET_ID" --arg v "$ASSET_VERSION" \
  '{name:"API tutorial",project_id:$p,markdown:("# API tutorial\n\n![Chart](asset://"+$a+")\n"),asset_versions:{($a):$v}}' \
  | ws "$WS_BASE/api/v1/decks" --json @-)
DECK_ID=$(jq -r '.id' <<<"$DECK")
VERSION_ID=$(jq -r '.current_version_id' <<<"$DECK")
ISSUE=$(ws "$WS_BASE/api/v1/projects/$PROJECT_ID/issues" --json '{"title":"Verify exports"}')
ISSUE_ID=$(jq -r '.id' <<<"$ISSUE")
jq -nc --arg d "$DECK_ID" --arg v "$VERSION_ID" '{deck_id:$d,version_id:$v}' \
  | ws "$WS_BASE/api/v1/preview" --json @- | jq '{slide_count,errors}'

for FORMAT in pdf pptx png png_zip; do
  KEY="tutorial-$VERSION_ID-$FORMAT"
  JOB=$(jq -nc --arg v "$VERSION_ID" --arg f "$FORMAT" \
    '{version_id:$v,format:$f} + (if $f == "png" then {slide_number:1} else {} end)' \
    | ws "$WS_BASE/api/v1/decks/$DECK_ID/exports" -H "Idempotency-Key: $KEY" --json @-)
  JOB_ID=$(jq -r '.id' <<<"$JOB")
  DEADLINE=$((SECONDS + 180))
  while true; do
    STATUS=$(jq -r '.status' <<<"$JOB")
    case "$STATUS" in
      succeeded) break ;;
      failed) printf 'Export failed; inspect job %s\n' "$JOB_ID" >&2; exit 1 ;;
      queued|running) ;;
      *) printf 'Unexpected job status\n' >&2; exit 1 ;;
    esac
    if (( SECONDS >= DEADLINE )); then printf 'Polling deadline exceeded\n' >&2; exit 1; fi
    sleep 2
    REMAINING=$((DEADLINE - SECONDS))
    if (( REMAINING <= 0 )); then printf 'Polling deadline exceeded\n' >&2; exit 1; fi
    JOB=$(ws "$WS_BASE/api/v1/exports/$JOB_ID" --max-time "$REMAINING")
  done
  EXTENSION=$FORMAT
  if [[ "$FORMAT" == png_zip ]]; then EXTENSION=zip; fi
  ws "$WS_BASE/api/v1/exports/$JOB_ID/download" --output "tutorial.$EXTENSION"
  EXPECTED=$(jq -r '.result.sha256' <<<"$JOB")
  ACTUAL=$(shasum -a 256 "tutorial.$EXTENSION" | awk '{print $1}')
  test "$EXPECTED" = "$ACTUAL"
done

ws "$WS_BASE/api/v1/issues/$ISSUE_ID" -X PATCH --json '{"expected_revision":1,"status":"done"}' \
  | jq '{id,status,revision}'
ws "$WS_BASE/api/v1/audit?limit=20" | jq 'map({action,resource_id})'
printf 'Retained project: %s\n' "$PROJECT_ID"
```

The helper sends authentication through a temporary file descriptor, not a token literal in curl's command-line header argument.
It does not use cookie files or follow redirects.
Use `sha256sum` instead of `shasum -a 256` if that is the checksum utility installed on your system.
For a broader repository-provided acceptance run, use `uv run python -m scripts.api_smoke --base-url <origin> --token-file <private-file>`.
That CLI also tests Korean/Mermaid content, independent branch edits, merging, image downloads, PPTX entries, and response hash headers, and retains its demo project.

## Complete Python workflow

Install `httpx` and Pillow, save the following as `api_example.py`, and run it with an already issued private token file:

```bash
python api_example.py --base-url https://webslides.parklab.work --token-file /private/path/web-slides-token
```

It creates an in-memory PNG, saves pinned Markdown, edits a branch, merges it, previews, exports, verifies downloads, and completes an issue.
It writes `tutorial.pdf`, `tutorial.pptx`, `tutorial.png`, and `tutorial.zip` in the current directory and retains the created server resources.
No provider connection or paid generation request is made.

```python
import argparse
import hashlib
import io
import stat
import time
import uuid
import zipfile
from pathlib import Path

import httpx
from PIL import Image

parser = argparse.ArgumentParser()
parser.add_argument("--base-url", required=True)
parser.add_argument("--token-file", required=True, type=Path)
args = parser.parse_args()
origin = httpx.URL(args.base_url)
assert origin.scheme == "https" or (
    origin.scheme == "http" and origin.host in {"localhost", "127.0.0.1", "::1"}
)
assert not origin.username and not origin.password and not origin.query and not origin.fragment
assert origin.path in ("", "/")
assert stat.S_IMODE(args.token_file.stat().st_mode) & 0o077 == 0
token = args.token_file.read_text().strip()
assert token.startswith("wst_")


def without_cookie(request):
    request.headers.pop("cookie", None)


client = httpx.Client(
    base_url=str(origin),
    headers={"Authorization": "Bearer " + token},
    timeout=60,
    follow_redirects=False,
    trust_env=False,
    event_hooks={"request": [without_cookie]},
)


def api(method, path, **kwargs):
    response = client.request(method, "/api/v1" + path, **kwargs)
    if response.status_code not in (200, 201, 202):
        raise RuntimeError(f"API request failed: HTTP {response.status_code}")
    return response.json()


def wait_export(job):
    deadline = time.monotonic() + 180
    while job["status"] in ("queued", "running"):
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("Export polling deadline exceeded")
        time.sleep(min(2, remaining))
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("Export polling deadline exceeded")
        job = api("GET", "/exports/" + job["id"], timeout=min(30, remaining))
    if job["status"] != "succeeded":
        raise RuntimeError("Export failed; inspect the job using its UUID")
    return job


with client:
    assert api("GET", "/me")["actor_type"] == "agent"
    project = api("POST", "/projects", json={"name": "Python API tutorial"})
    image = io.BytesIO()
    Image.new("RGB", (96, 54), "#3154a5").save(image, format="PNG")
    asset = api(
        "POST",
        "/assets/upload",
        data={"name": "Tutorial image"},
        files={"file": ("chart.png", image.getvalue(), "image/png")},
    )
    pins = {asset["id"]: asset["current_version_id"]}
    markdown = (
        "# Tutorial\n\nOpening.\n\n![Chart](asset://"
        + asset["id"]
        + ")\n\n---\n\n# Review\n\nDraft.\n"
    )
    deck = api(
        "POST",
        "/decks",
        json={
            "name": "Python tutorial",
            "project_id": project["id"],
            "markdown": markdown,
            "asset_versions": pins,
        },
    )
    root = "/decks/" + deck["id"]
    branch = api(
        "POST",
        root + "/branches",
        json={"name": "review", "from_version_id": deck["current_version_id"]},
    )
    source = api(
        "POST",
        root + "/versions",
        json={
            "branch_id": branch["id"],
            "expected_version_id": branch["head_version_id"],
            "markdown": markdown.replace("Draft.", "Reviewed."),
            "asset_versions": pins,
        },
    )
    target = api(
        "POST",
        root + "/versions",
        json={
            "expected_version_id": deck["current_version_id"],
            "markdown": markdown.replace("Opening.", "Updated opening."),
            "asset_versions": pins,
        },
    )
    merged = api(
        "POST",
        root + "/merge",
        json={
            "source_branch_id": branch["id"],
            "expected_version_id": target["id"],
            "message": "Merge reviewed content",
        },
    )
    assert merged["parent_id"] == target["id"] and merged["merge_parent_id"] == source["id"]
    assert merged["asset_versions"] == pins
    preview = api("POST", "/preview", json={"deck_id": deck["id"], "version_id": merged["id"]})
    assert preview["html"] and not preview["errors"]
    issue = api(
        "POST", "/projects/" + project["id"] + "/issues", json={"title": "Verify downloads"}
    )
    for format in ("pdf", "pptx", "png", "png_zip"):
        options = {"version_id": merged["id"], "format": format}
        if format == "png":
            options["slide_number"] = 1
        job = api(
            "POST", root + "/exports", headers={"Idempotency-Key": str(uuid.uuid4())}, json=options
        )
        job = wait_export(job)
        path = "/api/v1/exports/" + str(uuid.UUID(job["id"])) + "/download"
        assert job["download_url"] == path
        response = client.get(path)
        if response.status_code != 200:
            raise RuntimeError("Authenticated download failed")
        data = response.content
        digest = hashlib.sha256(data).hexdigest()
        assert digest == job["result"]["sha256"] == response.headers["X-Content-SHA256"]
        assert len(data) == job["result"]["size"]
        if format == "pdf":
            assert data.startswith(b"%PDF-")
        elif format == "png":
            assert data.startswith(b"\x89PNG\r\n\x1a\n")
            with Image.open(io.BytesIO(data)) as slide:
                assert slide.size == (1280, 720)
        else:
            with zipfile.ZipFile(io.BytesIO(data)) as archive:
                if format == "pptx":
                    assert {
                        "[Content_Types].xml",
                        "ppt/presentation.xml",
                        "ppt/slides/slide1.xml",
                    } <= set(archive.namelist())
                else:
                    assert archive.namelist() == ["slide-001.png", "slide-002.png"]
                    for name in archive.namelist():
                        with Image.open(io.BytesIO(archive.read(name))) as slide:
                            assert slide.format == "PNG" and slide.size == (1280, 720)
                assert archive.testzip() is None
        extension = "zip" if format == "png_zip" else format
        Path("tutorial." + extension).write_bytes(data)
    api(
        "PATCH",
        "/issues/" + issue["id"],
        json={"expected_revision": issue["revision"], "status": "done"},
    )
    for endpoint in ("fonts", "models", "connections", "audit"):
        assert isinstance(api("GET", "/" + endpoint), list)
    print("Retained project:", project["id"])
```

The program never prints the token, uses no cookie authentication, and verifies downloaded bytes before writing them.
For production automation, retain project/deck/job IDs in your own state so recovery can use GET instead of creating another set of resources.

## Troubleshooting

| Symptom | Check |
| --- | --- |
| 401 on all API calls | Use a nonrevoked `wst_` token, not an OpenRouter key or Google token |
| Browser POST fails but GET works | Read `/me` again and send its `X-CSRF-Token`; check Origin |
| API download URL returns 401 | Authenticate the download itself; `/api/v1/` download URLs are not public |
| 404 for a known UUID | Confirm owner, resource type, and parent deck/asset association |
| 409 when saving | Fetch the latest main or branch head and reconcile edits |
| 409 when updating an issue | Use the latest integer `revision`, not a deck UUID |
| 409 for a reused job key | Compare resolved revision, format/options, and generation catalog/defaults |
| Export POST immediately returns succeeded | A matching cached successful export was reused |
| Generation POST returns inactive-model conflict | Refresh `/models` and select an active compatible entry |
| Connection creation succeeded but generation fails | Inspect connection `status`, provider balance, and job `error` |
| New asset upload does not change an old deck | The revision is pinned; explicitly select the new asset version in a new revision |
| Diff is empty after an image update | Markdown diff does not compare asset-version maps |
| Job remains queued | Inspect worker availability and queue load; do not duplicate paid requests |
| Export fails after a deployment | Check renderer and font snapshot availability, then inspect sanitized job error |
| A diagram saves but does not export | Inspect preview errors and remove unsupported Mermaid content |
| Asset upload fails below 500 MiB of visible images | Older versions and pending generation reservations also consume the budget |
| Job GET returns 200 with an error | HTTP transport succeeded; the asynchronous operation failed |

Use the response schema and status fields as the integration contract.
Archive content when you want to hide it, create new revisions when you want to change it, and keep explicit version IDs when you need reproducible results.
