# The Slides drive API

This is the surface a program uses to build a deck, look at what it
made, and put it on a projector. A person's browser never touches it —
the screens are a separate thing, and neither one accepts the other's
credential.

They do write the same document, though. Since slice 2 there is a
browser editor at `/d/{sqid}/edit`, and an op you post here and a
keystroke somebody types there land in one Yjs document as one ordered
log. Your `seq` moves when they type; theirs moves when you post. If you
want to know how a person's tab keeps up, "The live-sync wire" below
describes what the editor speaks.

Everything below assumes the instance is at `https://slides.example`.
Replace it with your own.

## Getting a token

A token belongs to an **agent**, and an agent belongs to a person — its
operator. Sign in, open **Agents** from the account menu, give the
agent a name, and press Create. The token is shown once, on the page
Create answers with, and never again: only its fingerprint is stored,
so there is nothing to show you a second time, and it is in no URL to
go back to. Copy it before you leave that page; lose it and you revoke
the agent and make another.

A name is 2 to 32 characters of lowercase letters, digits and hyphens
— `claude`, `codex`, `release-bot`. It has to be different from every
other agent you have that is still live.

The token looks like `sl_` followed by forty letters and digits. Send
it on every request:

```
Authorization: Bearer sl_...
```

Two rules worth knowing before you build anything on this:

- **The API never reads a session cookie**, and the screens never read
  a bearer. A request to `/api/v1/...` with a perfectly good session
  and no token is a 401.
- **An agent sees exactly what its operator sees.** It can read and
  write every deck that person can, and nothing else. It is not an
  administrator and it cannot mint another token.

Every change an agent makes is recorded against the agent, not the
person: the actor is `agent:<id>`, and the deck list shows it as
🤖 `claude~you@example.com`. Revoking the agent stops the token
immediately; the history it already wrote keeps its name.

A token also stops working when its **operator** does. If the person
who minted it is taken off `SLIDES_ALLOWED_ADDRESSES`, every request
this token makes is a `401` from then on — the same 401 a revoked token
gets, and for the same reason: an agent sees exactly what its operator
sees, and an operator who is no longer admitted here sees nothing. This
is checked on every request rather than at minting, so an offboarding
takes effect without anybody hunting down tokens.

## Shapes

Requests with a body send JSON and say so:
`Content-Type: application/json`. Anything else is a 415. A body over
1 MiB is a 413.

Every answer with a body is JSON. Every error is:

```json
{"error": "invalid", "message": "op 1: no slide \"abc\"", "op": 1}
```

`error` is a fixed English identifier — branch on it. `message` and
`op` are only on some of them.

| Status | `error`                  | What happened |
|--------|--------------------------|---------------|
| 401    | `unauthorized`           | No token, an unknown token, a revoked one, or one whose operator is no longer admitted on this instance. All four answer identically. |
| 404    | `not_found`              | No such deck, slide or path — including a deck the operator cannot see. A deck that is not yours does not exist. |
| 405    | `method_not_allowed`     | Right path, wrong verb. |
| 409    | `conflict`               | Somebody else wrote to the deck while this batch was landing, twice. Re-read the deck and decide what your batch now means. |
| 413    | `too_large`              | The body is over 1 MiB. |
| 415    | `unsupported_media_type` | The body is not `application/json`. |
| 422    | `invalid`                | The request was read and refused. `message` says why; `op` is the index of the operation at fault, when the failure was about one. A body carrying anything after its JSON value is refused here too, rather than half-read. |
| 429    | `busy`                   | You already have four exports running. Wait for one to finish; nothing was started. |
| 500    | `internal`               | Our fault. The server log has a matching line. |

---

## Endpoints

### `GET /api/v1/decks` — every deck you can open

```sh
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
  https://slides.example/api/v1/decks
```

```json
{"decks": [
  {"sqid": "k7m2xq9dwb", "title": "Q3 review", "slides": 12,
   "rung": "private", "updated_at": "2026-09-07T09:41:02Z", "seq": 34}
]}
```

Most recently edited first. `sqid` is the deck's public name and what
every other endpoint takes. `rung` is who can open it: `private`,
`granted` or `team`. `seq` is the deck's version — it moves on every
write and on nothing else.

### `POST /api/v1/decks` — make one

```sh
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Q3 review", "theme": "ink"}' \
  https://slides.example/api/v1/decks
```

```json
{"sqid": "k7m2xq9dwb"}
```

201. `title` is required; `theme` is one of `paper`, `ink`, `sunset`,
`forest`, `slate`, `candy`, and defaults to `paper`. The deck is owned
by your operator and starts on the `private` rung, exactly as one made
from the screen does — and like one made from the screen, it opens
with a title slide already on it.

### `GET /api/v1/decks/{sqid}` — the whole document

```sh
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
  https://slides.example/api/v1/decks/k7m2xq9dwb
```

```json
{"sqid": "k7m2xq9dwb", "seq": 34,
 "meta": {"title": "Q3 review", "theme": "ink", "accent": null,
          "fonts": null, "aspect": "16:9"},
 "slides": [{"id": "hq3ktna7vc2m", "layout": "flow", "template": "title",
             "background": {"kind": "theme", "value": ""},
             "phone": "inherit", "transition": "none",
             "notes": [], "blocks": [...]}],
 "assets": {}}
```

The answer carries `ETag: "34"` — the seq, quoted. Send it back and an
unchanged deck is a 304 with no body, which costs the server nothing:

```sh
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
  -H 'If-None-Match: "34"' \
  https://slides.example/api/v1/decks/k7m2xq9dwb
```

Poll this rather than re-fetching. It is how you tell whether anybody —
a person in the editor, another agent — has moved the deck since you
last looked.

### `POST /api/v1/decks/{sqid}/ops` — change it

Everything that changes a deck goes through here, as a batch applied in
order inside one transaction.

```sh
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ops": [
        {"op": "add_slide", "layout": "flow", "template": "section"},
        {"op": "set_meta", "title": "Q3 review, final"}
      ]}' \
  https://slides.example/api/v1/decks/k7m2xq9dwb/ops
```

```json
{"seq": 35, "ids": {"0": "hq3ktna7vc2m"}}
```

**You do not have to invent ids.** `add_slide`, `add_block` and
`duplicate_slide` each need one, and if you leave it out the server
mints it and hands it back keyed by the operation's index — `"0"` is
the first operation you sent. Supply your own and it is used as given.
For `duplicate_slide` the returned id is the new slide's; the copied
blocks get ids too, and you read them back off the deck.

**A refused batch changes nothing.** Not the good operations in front
of the bad one, not the seq — the whole batch is checked before the
first byte is written:

```json
{"error": "invalid", "op": 1, "message": "op 1: no slide \"nosuchslide\""}
```

If two writers land at once you may get a `409 conflict`. The batch
applied nothing. Re-read the deck and send it again if it still makes
sense.

### `GET /api/v1/decks/{sqid}/slides/{id}.html` — look at a slide

```sh
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
  'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.html?vp=phone'
```

One `<section>` of HTML — the same markup the viewer renders — and
nothing around it. `vp=phone` renders the phone layout; leave it off
for the desktop one. This is how you check what you built without a
browser.

### `GET /api/v1/decks/{sqid}/slides/{id}.svg` — look at it as a picture

```sh
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
  'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.svg' \
  -o slide.svg
```

The slide as one 1920 by 1080 SVG: the same paint list the PDF is made
of, so what you see here is what the export will put on a page. Text is
`<text>` with a `<tspan>` per run at its measured position, pictures are
embedded as data URIs where they can be, and nothing in the file
fetches anything.

`?w=` is a rendering hint and is in the `ETag`; it changes nothing about
the bytes today. The `ETag` is `(seq, slide, w)`, so send
`If-None-Match` and you will get a `304` until somebody edits the deck.

It is not a substitute for reading the document: a picture is for
looking at what you made, and `GET /api/v1/decks/{sqid}` is for knowing
what is in it.

### `POST /api/v1/decks/{sqid}/export` — make a PDF

```sh
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
  -H "Content-Type: application/json" -d '{"notes": true}' \
  https://slides.example/api/v1/decks/k7m2xq9dwb/export
```

```json
{"job": "9Qb1t0RmS3aXk7Zt2Fv8Lw",
 "status_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw",
 "pdf_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf"}
```

`202`, because nothing is ready yet: an export is a job, and what comes
back is where to watch it and where the file will be. The body is
optional; `{"notes": true}` adds the speaker notes as pages of their own
after each slide.

A deck over 200 slides is a `422` before anything starts. Four exports
running at once for one operator is a `429` — wait for one to finish.

### `GET /api/v1/decks/{sqid}/export/{job}` — how is it going

```json
{"state": "running", "progress": "slide 3 of 8"}
```

```json
{"state": "done", "progress": "slide 8 of 8", "warnings": [
  {"kind": "asset", "slide": "hq3ktna7vc2m",
   "detail": "image \"a1b2c3d4e5f6\" is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF"}]}
```

`state` is `running`, `done` or `failed`, and it is the word to branch
on. A `failed` job carries a `message` written for a person. A `done`
one carries `warnings`: everything worth saying about the file — a block
that ran past its box, a picture that could not go in, a character no
embedded face can draw — each naming the slide it is about. An empty
list means the PDF is the deck.

Poll it, a second or two apart. `"expired": true` on a `done` job means
the file is no longer there; make another. This endpoint answers `done`
for as long as the file does — the whole hour — so it and the `.pdf`
below never disagree about whether an export still exists.

### `GET /api/v1/decks/{sqid}/export/{job}.pdf` — take the file

```sh
curl -L -H "Authorization: Bearer $SLIDES_TOKEN" \
  https://slides.example/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf \
  -o deck.pdf
```

The bytes, as `application/pdf` — or a `302` to a presigned URL when the
instance has an object store, which is why the `-L` is there.

**The file lives for an hour.** With no bucket bound it is held in the
instance's memory, which is also why it is capped: an export bigger than
the instance will hold fails with a message saying so.

An export belongs to the operator, not to the agent that started it — so
a person can watch it from the screen and any of that person's agents can
fetch it. Another operator's job is a `404`, exactly as another
operator's deck is.

### `POST /api/v1/decks/{sqid}/present` — start a talk

```sh
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
  https://slides.example/api/v1/decks/k7m2xq9dwb/present
```

```json
{"session": "p4wq7nzk2h", "code": "K7MQ2X",
 "present_url": "https://slides.example/p/p4wq7nzk2h",
 "notes_url": "https://slides.example/p/p4wq7nzk2h/notes?t=...",
 "remote_url": "https://slides.example/r/K7MQ2X?t=..."}
```

Call it again on the same deck and you get the same session back, so a
projector already showing `present_url` keeps showing the same talk.
What you do get fresh each time is the remote token, which means the
previous `notes_url` and `remote_url` stop working.

Treat the three URLs differently. `present_url` is safe to put on a
screen in front of a room — it only watches. `notes_url` and
`remote_url` carry the token that *drives* the deck, so they are
secrets: send them to the presenter, not to the audience.

`present_url` is a *projector* address, though, and not an audience
one. It lives as long as the talk and no revocation reaches it, so
sending it to a room is sending something you cannot take back. For an
audience, mint a share link and let them follow: `/s/{token}?follow=1`
shows the presenter's own slide behind a capability that can be
revoked, and everything that page then reads — its slide fragments, its
stream at `/s/{token}/stream`, its pictures — resolves the token again
on every request. Revoke the link mid-talk and it stops, immediately.

Every command against a session is logged as `remote:{session}` rather
than as whoever started it. The remote token is transferable by design
— that is the feature — so the server cannot say who is holding it, and
§6.2's "every write names its real actor" is kept by naming the
capability and logging the issuer beside it.

This JSON is the only place the pairing block exists for a session an
agent started. Press Present in a browser and the presenter view shows
the code and a QR to scan it with; that block belongs to whoever
started the talk, and for your session that is the agent, not the
person opening the page — so `notes_url` renders the talk and the
controls, and no code and no QR. Pass on what you were given: the
`code` for somebody to type into `/r/`, or `remote_url` outright. It is
not recoverable afterwards, and calling present again mints a fresh
token, which retires the URLs you are holding.

---

## The operations

Every operation is an object with an `op` key. Ids are twelve lowercase
base32 characters and are minted by the server when you omit them. The
vocabularies:

- **layout**: `flow`, `canvas`
- **template** (flow slides): `title`, `title-body`, `two-column`,
  `section`, `image-full`, `quote`, `blank`
- **region** (blocks on a flow slide): `heading`, `body`, `aside`, `media`
- **block type**: `heading`, `text`, `list`, `quote`, `image`,
  `columns`, `code`, `shape`, `table`, `divider`, `spacer`
- **background kind**: `theme`, `color`, `image`
- **transition**: `none`, `fade` — **slide phone**: `inherit`, `reflow`
- **size**: `s`, `m`, `l`

`after` says where something goes: absent appends, `null` puts it
first, an id puts it after that one. A `box` is `{x, y, w, h}` as
fractions of the 16:9 stage. Text arrives as Markdown and is converted
server-side.

Each example below is one batch, so you can paste any of them straight
into the `ops` body above. `$D` is the deck sqid, `$S` a slide id and
`$B` a block id.

### Deck

**`set_meta`** — the deck's title, theme, accent and font pair. Send
`null` for `accent` or `fonts` to clear one.

```sh
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" -H "Content-Type: application/json" \
  -d '{"ops":[{"op":"set_meta","title":"Q3 review","theme":"ink","accent":"#c0392b",
               "fonts":{"heading":"Inter","body":"Inter"}}]}' \
  https://slides.example/api/v1/decks/$D/ops
```

### Slides

**`add_slide`** — a new slide. `id` optional (minted and returned).

```sh
-d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body","after":null}]}'
```

**`remove_slide`** — delete one, and everything on it.

```sh
-d '{"ops":[{"op":"remove_slide","id":"'"$S"'"}]}'
```

**`move_slide`** — reorder. `"after": null` moves it to the front. The
view's `slides` come back in the new order; under the hood the slide
keeps its identity and gets an `order` written on it, exactly as
`move_block` does, so somebody typing on that slide at the same moment
loses nothing (spec §2.1, §2.2).

```sh
-d '{"ops":[{"op":"move_slide","id":"'"$S"'","after":null}]}'
```

**`duplicate_slide`** — copy a slide and everything on it. `new_id` and
`new_block_ids` are both optional; leave them out and the server mints
as many block ids as the slide has.

```sh
-d '{"ops":[{"op":"duplicate_slide","id":"'"$S"'"}]}'
```

**`set_slide`** — a slide's own settings.

```sh
-d '{"ops":[{"op":"set_slide","id":"'"$S"'","layout":"flow","template":"quote",
             "background":{"kind":"color","value":"#101418"},
             "phone":"reflow","transition":"fade"}]}'
```

**`set_notes`** — the speaker notes, as Markdown.

```sh
-d '{"ops":[{"op":"set_notes","id":"'"$S"'","markdown":"Pause here. Ask about hiring."}]}'
```

### Blocks

**`add_block`** — put something on a slide. `id` optional. On a flow
slide give a `region`; on a canvas slide give a `box` and optionally
`z`.

```sh
-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"heading",
             "region":"heading","props":{"level":1},"markdown":"Revenue"}]}'
```

```sh
-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"image",
             "box":{"x":0.1,"y":0.2,"w":0.5,"h":0.4},"z":2,
             "props":{"asset":"a1"}}]}'
```

**`remove_block`** — take one off.

```sh
-d '{"ops":[{"op":"remove_block","slide":"'"$S"'","id":"'"$B"'"}]}'
```

**`move_block`** — reorder within its slide. The view's `blocks` come
back in the new order; under the hood the block keeps its identity and
gets an `order` written on it, so somebody typing into it at the same
moment loses nothing (spec §2.2).

```sh
-d '{"ops":[{"op":"move_block","slide":"'"$S"'","id":"'"$B"'","after":null}]}'
```

A `columns` block's `props.children` names blocks on the same slide,
each at most once and each in at most one `children` list on the slide,
and never a block the columns block already sits under (spec §2.4). An
op that would share a child or close a cycle is refused with a message
saying which id.

**`set_block`** — its props, region, box or z. `null` for `region` or
`box` clears that one.

```sh
-d '{"ops":[{"op":"set_block","slide":"'"$S"'","id":"'"$B"'",
             "props":{"level":2},"region":"body","box":null,"z":1}]}'
```

**`set_text`** — replace the words in a text-bearing block (`heading`,
`text`, `list`, `quote`, `code`, `shape`).

```sh
-d '{"ops":[{"op":"set_text","slide":"'"$S"'","id":"'"$B"'",
             "markdown":"- Up 14%\n- Two new regions"}]}'
```

**`set_phone`** — a block's phone layer: additions to the desktop
layout, never a replacement for it.

```sh
-d '{"ops":[{"op":"set_phone","slide":"'"$S"'","id":"'"$B"'",
             "phone":{"hidden":false,"order":0,"size":"l"}}]}'
```

### Assets

**`register_asset`** — name an image the deck can use. The bytes go up
separately (see "Getting the bytes there" below); this records the
result. `ref` is the object store's address for those bytes — a hex
SHA-256, because the store is content-addressed.

```sh
-d '{"ops":[{"op":"register_asset","id":"a1",
             "ref":"9f2c1e...ab","name":"roadmap.png","width":1600,"height":900,
             "alt":"Three phases, each with a date"}]}'
```

**`remove_asset`** — drop the registration. The bytes stay in the store,
which is content-addressed and shared.

```sh
-d '{"ops":[{"op":"remove_asset","id":"a1"}]}'
```

---

## Getting the bytes there

Images never travel through this API. They go browser-to-bucket, and
three endpoints on the *signed-in* surface arrange it. They take a
session cookie rather than a bearer token, because they are what the
editor's own upload uses.

**An agent cannot add a picture today.** It can name a ref in a
`register_asset` op, and the document will hold it, but no surface will
serve those bytes: serving requires a verification row, and a
verification row is only ever written by the registration endpoint
below, for an upload it signed itself. Naming an address is not a way
to publish one. A bearer-authenticated upload is the obvious next
thing to build and is not built.

Limits, on all three: 20 MiB per image, and PNG, JPEG, WebP or SVG.

### `POST /d/{sqid}/uploads` — where do these bytes go

```json
{"name": "roadmap.png", "size": 184320, "type": "image/png", "hash": "9f2c1e...ab"}
```

`hash` is the file's SHA-256, computed by the client. Nothing is
addressed by it. It is recorded, and on the development path — where
this app receives the PUT and hashes the bytes anyway — a disagreement
is reported as `hash_mismatch`; on a real bucket it is not consulted at
all. The address the bytes go to is the app's own, always.

The answer is where to PUT and what header to send:

```json
{"ref": "4d0a77...e1",
 "url": "https://bucket.example/4d0a77...e1?X-Amz-Signature=...",
 "headers": {"Content-Type": "image/png"}}
```

**That `ref` is a staging address, not the address the picture will be
served from.** It is minted here, once, for this deck and this
principal, and it is the only address this app will ever sign a PUT
for. Two things follow, and both are the point:

- A caller cannot ask for a PUT to an address something is already
  served from, in this deck or any other. A presigned PUT is
  unconditional, so an address a browser can name is an address a
  browser can overwrite — which is how, before this, replacement bytes
  could change a registered picture with no document update anywhere.
- Registration will only look at bytes staged this way. Knowing a ref
  is not a way to get it registered; see the next endpoint.

Sign twice for the same file and you get two staging addresses. Ask for
one and never use it and nothing happens: a staged upload nobody
registered within two hours stops being registrable.

With no object store bound it answers `503` and
`{"error": "no_store", "message": "…"}` instead. That is the honest
answer, not a fallback — there is nowhere for the bytes to go.

In development the bound store is a directory (`blobs.Dir`), which has
nothing to presign, so `url` comes back as a same-origin
`/d/{sqid}/uploads/{ref}` and this app receives the PUT itself. That
path resolves the same receipt before it reads a byte, so it is not a
way round the staging rule either. On a real bucket it answers `404`:
20 MiB does not go through an app process that has one small instance's
memory.

### `POST /d/{sqid}/assets` — what actually landed

```json
{"ref": "4d0a77...e1", "name": "roadmap.png", "width": 1600, "height": 900,
 "alt": "Three phases, each with a date", "decorative": false}
```

`ref` here is the **staging address** the sign endpoint answered with,
and nothing else is accepted. This endpoint resolves it to the upload
receipt this app wrote when it signed the PUT: same deck, same
principal, not already registered, not older than two hours. Anything
else — a ref from another deck, a ref you happen to know, a receipt
somebody else staged, a receipt already spent — is `404`
`not_found`, the same answer an address holding nothing gets.

That matters more than it looks. A ref is a content address, so knowing
one is knowing the SHA-256 of somebody's file, and this endpoint writes
the verification row that `GET /d/{sqid}/assets/{ref}` requires. If a
ref alone were enough, knowing one would be permission to read it.

The server then reads the staged object, sniffs the bytes (a file that
*says* PNG and is not one is refused here, which is the only place the
bytes are ever looked at), sanitises an SVG — scripts, event handlers
and foreign HTML stripped; anything still pointing outside the document
refused — and **puts what it read back as a final object addressed by
its own SHA-256**. That final address is what the deck refers to, and
no browser has ever been handed a PUT for it. Alternative text is
required unless `decorative` is true (§2.4).

It answers `200` with the record to register:

```json
{"id": "a1b2c3d4e5f6", "ref": "9f2c1e...ab", "name": "roadmap.png",
 "width": 1600, "height": 900, "alt": "Three phases, each with a date"}
```

**The `ref` that comes back is never the one that went in.** The one
that went in was a staging address; this one is the hash of the bytes
the server read, which for a sanitised SVG is not even the hash of what
was uploaded. Register what came back.

It does not write the document. The client applies `register_asset`
with this record itself, so an upload reaches the deck through the same
op path, the same update log and the same undo step as every other
change.

Refusals are `{"error": "...", "message": "..."}` — `no_store`,
`too_large`, `not_an_image`, `unsafe_svg`, `alt_required`, `invalid`,
`not_found`. (`unsupported_type` and `already_verified` are the sign
endpoint's own and `hash_mismatch` is the development PUT's;
`unsupported_type` is a *declared* type outside the four, where here
the bytes are what is judged and a file of the wrong kind is
`not_an_image`.) The `error` word is fixed
English for a program to branch on; the `message` is for a person and
follows `Accept-Language`.

Registering also records that the ref was verified for this deck, and
`GET /d/{sqid}/assets/{ref}` requires that record as well as the deck's
own `assets` map: a `register_asset` op naming a ref that never went
through this endpoint is accepted by the document and the picture is
never served.

### `GET /d/{sqid}/assets/{ref}` — the bytes back

A `302` to a presigned GET (one hour), or the file itself where the
store cannot presign. Two more routes serve the same bytes to the
surfaces that have no session: `/s/{token}/assets/{ref}` for a share
link and `/p/{session}/assets/{ref}` while presenting. The renderer
writes whichever base belongs to the page it is drawing.

Every one of them checks the ref against **that deck's** `assets` map
before serving a byte. A ref is a content address, so knowing one is
knowing the SHA-256 of somebody's file; without that check any
capability for any deck would fetch any object in the bucket.

### For the operator: CORS on the bucket

The presigned PUT is made by the browser, cross-origin, straight to the
object store. That only works if the bucket allows it, and nothing in
this app can arrange it for you. Configure the store's CORS policy to
allow `PUT` from this instance's origin, with the `Content-Type` header,
and expose `ETag`. Without it uploads fail in the browser with no useful
error — the request never reaches this app, so there is nothing here to
log.

Development needs none of this: `blobs.Dir` has no presigning, so the
PUT is same-origin.

---

## The live-sync wire

Four more routes on the signed-in surface, which is what the browser
editor is built on. You do not need them — `/api/v1/decks/{sqid}/ops`
is the supported way for a program to write, and polling the deck with
`If-None-Match` is the supported way to watch. They are documented
because they are how the same deck is edited by a person while your
agent is holding it, and because the framing is easy to get subtly
wrong if you ever do speak it. They take a session cookie, not a bearer,
and the two POSTs among them sit behind the same origin check every
other screen does — see "A note on CSRF" at the foot of this file.

The document underneath is a Yjs document. `POST /ops` is a batch of
operations the server turns into one Yjs update; these routes carry
Yjs updates directly. Both end up as rows in the same log, in one
order, each stamped with the actor that wrote it.

### `GET /d/{sqid}/updates?since=N` — the bytes after seq N

`application/octet-stream`. Every update after seq `N`, in seq order,
each one framed as a **4-byte big-endian length followed by that many
bytes**. There is no other structure — no header, no trailer, no count.

`Slides-Seq` on the response is the highest seq in the body, or `N`
itself when the body is empty. Take your cursor from that header and
never from a separate query: it is derived from the bytes you were
actually handed, so a cursor read from it can never be ahead of what
you received.

At `since=0` the answer may begin from a periodic snapshot rather than
from the first update ever written, in which case `Slides-Snapshot:
<seq>` says which seq that first frame brings you to. A snapshot is
derived data — it is a shortcut, never the truth — so a deck opens
identically with it and without it.

A `since` that is missing, negative or not a number is a `400`.

### `POST /d/{sqid}/updates` — one update

The body is one Yjs v1 update, 1 MiB at most. The answer is `204` with
`Slides-Seq` for the row it wrote. Then it is broadcast, so every other
open tab learns about it.

A `422` here means the core read your bytes and refused them, which is
an ordinary answer rather than a server fault: these arrive from
browsers. A `413` is the size cap. A `409` means two writers landed at
once, twice — reread and decide what your update now means.

### `GET /d/{sqid}/stream` — server-sent events

```
event: update
data: 41

event: presence
data: [{"client":"…","name":"Ana","colour":"#6b4","slide":"…","block":"…"}]

: ping
```

One of each on connect, a `: ping` every 15 seconds, and the connection
closes after five minutes for you to reopen.

**`update` carries a seq and no bytes.** It is advisory — "there is
something at least this new" — and the bytes come from `GET /updates`.
`presence` carries the whole set every time, never a delta, so a
subscriber that falls behind is dropped and reconnects rather than
having events silently skipped.

### `POST /d/{sqid}/presence` — where you are

```json
{"client": "9f3c…", "slide": "hq3ktna7vc2m", "block": "blkheading22"}
```

`client` identifies the tab, not the person, and is required. Post it
every 20 seconds and whenever the person moves; two missed heartbeats
and the server drops you from the set.

The name and the colour are **stamped by the server** from your user
row. Sending your own does nothing — otherwise presence would be a
place to write somebody else's name onto somebody else's screen.
Presence is never document state: it lives in memory, reaches no log,
is in no snapshot, and is gone when the process restarts.

### One note if you write Yjs updates by hand

Text is an `XmlFragment` of `<paragraph>` elements whose formatting
attributes are the marks, and the marks are named after the attributes:
`b`, `i`, `c`, `h`, `href`. **`href` is stored as the one-key map
`{"href": "https://…"}`, not as a plain string** (spec §1 ruling 12).
That shape is y-prosemirror's, and it is not cosmetic: a plain string
throws inside its reader, and it deletes the text node when a read
throws — so the wrong shape loses the words, not just the link.

Readers on both sides are tolerant where writers are exact. `b`, `i`,
`c` and `h` are written `true` and read as set on any truthy value; an
`href` is read as either the map or a plain string. The deck's JSON
view — what `GET /api/v1/decks/{sqid}` hands you — flattens all of that
into runs with a plain `href` string, so none of this reaches you
unless you are writing updates yourself.

The gate on `POST /d/{sqid}/updates` checks the text you leave behind,
not the view of it (spec §2.6): a formatting attribute outside those
five, an element other than `<paragraph>` at the top of a text, or an
element or fragment inside a paragraph is a `422`, and nothing is
stored. The view would have read all of those; the editor would have
deleted the text on binding, which is why the check is on the
document.

Block order is a key, not the array. A slide's `blocks` array is the
set of its blocks; `order` on each block map (a number, array index
when absent) is the reading order, and the view sorts by it (spec
§2.2). Reorder by writing `order`, never by deleting and re-inserting a
block map — the second loses whatever another peer typed into it in the
meantime.

---

## A worked example

Build a two-slide deck from nothing:

```sh
export SLIDES_TOKEN=sl_...
export API=https://slides.example/api/v1

D=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"title":"Q3 review","theme":"ink"}' $API/decks | jq -r .sqid)

S=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body"}]}' \
      $API/decks/$D/ops | jq -r '.ids["0"]')

curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ops":[
        {"op":"add_block","slide":"'"$S"'","type":"heading","region":"heading",
         "props":{"level":1},"markdown":"Revenue"},
        {"op":"add_block","slide":"'"$S"'","type":"list","region":"body",
         "markdown":"- Up 14%\n- Two new regions"},
        {"op":"set_notes","id":"'"$S"'","markdown":"Pause on the second bullet."}
      ]}' $API/decks/$D/ops

# Look at what you made.
curl -s -H "Authorization: Bearer $SLIDES_TOKEN" "$API/decks/$D/slides/$S.html"
```

---

## A note on CSRF

The screens are protected by an origin check on every state-changing
request (`Sec-Fetch-Site`, `Origin`, `Referer`). A program has none of
those to send, and the check refuses a request that carries none of the
three — so `/api/v1` is mounted outside it and gated by the bearer
alone.

That is not a hole. A browser never attaches an `Authorization` header
by itself, so a page on some other origin cannot make a request this
API will authenticate, whatever it does with forms or credentials.
Cross-site request forgery is an attack on ambient credentials — a
cookie the browser sends for you — and there is no ambient credential
here.
