| 1 | # The Slides drive API |
| 2 | |
| 3 | This is the surface a program uses to build a deck, look at what it |
| 4 | made, and put it on a projector. A person's browser never touches it β |
| 5 | the screens are a separate thing, and neither one accepts the other's |
| 6 | credential. |
| 7 | |
| 8 | They do write the same document, though. Since slice 2 there is a |
| 9 | browser editor at `/d/{sqid}/edit`, and an op you post here and a |
| 10 | keystroke somebody types there land in one Yjs document as one ordered |
| 11 | log. Your `seq` moves when they type; theirs moves when you post. If you |
| 12 | want to know how a person's tab keeps up, "The live-sync wire" below |
| 13 | describes what the editor speaks. |
| 14 | |
| 15 | Everything below assumes the instance is at `https://slides.example`. |
| 16 | Replace it with your own. |
| 17 | |
| 18 | ## Getting a token |
| 19 | |
| 20 | A token belongs to an **agent**, and an agent belongs to a person β its |
| 21 | operator. Sign in, open **Agents** from the account menu, give the |
| 22 | agent a name, and press Create. The token is shown once, on the page |
| 23 | Create answers with, and never again: only its fingerprint is stored, |
| 24 | so there is nothing to show you a second time, and it is in no URL to |
| 25 | go back to. Copy it before you leave that page; lose it and you revoke |
| 26 | the agent and make another. |
| 27 | |
| 28 | A name is 2 to 32 characters of lowercase letters, digits and hyphens |
| 29 | β `claude`, `codex`, `release-bot`. It has to be different from every |
| 30 | other agent you have that is still live. |
| 31 | |
| 32 | The token looks like `sl_` followed by forty letters and digits. Send |
| 33 | it on every request: |
| 34 | |
| 35 | ``` |
| 36 | Authorization: Bearer sl_... |
| 37 | ``` |
| 38 | |
| 39 | Two rules worth knowing before you build anything on this: |
| 40 | |
| 41 | - **The API never reads a session cookie**, and the screens never read |
| 42 | a bearer. A request to `/api/v1/...` with a perfectly good session |
| 43 | and no token is a 401. |
| 44 | - **An agent sees exactly what its operator sees.** It can read and |
| 45 | write every deck that person can, and nothing else. It is not an |
| 46 | administrator and it cannot mint another token. |
| 47 | |
| 48 | Every change an agent makes is recorded against the agent, not the |
| 49 | person: the actor is `agent:<id>`, and the deck list shows it as |
| 50 | π€ `claude~you@example.com`. Revoking the agent stops the token |
| 51 | immediately; the history it already wrote keeps its name. |
| 52 | |
| 53 | A token also stops working when its **operator** does. If the person |
| 54 | who minted it is taken off `SLIDES_ALLOWED_ADDRESSES`, every request |
| 55 | this token makes is a `401` from then on β the same 401 a revoked token |
| 56 | gets, and for the same reason: an agent sees exactly what its operator |
| 57 | sees, and an operator who is no longer admitted here sees nothing. This |
| 58 | is checked on every request rather than at minting, so an offboarding |
| 59 | takes effect without anybody hunting down tokens. |
| 60 | |
| 61 | ## Shapes |
| 62 | |
| 63 | Requests with a body send JSON and say so: |
| 64 | `Content-Type: application/json`. Anything else is a 415. A body over |
| 65 | 1 MiB is a 413. |
| 66 | |
| 67 | Every answer with a body is JSON. Every error is: |
| 68 | |
| 69 | ```json |
| 70 | {"error": "invalid", "message": "op 1: no slide \"abc\"", "op": 1} |
| 71 | ``` |
| 72 | |
| 73 | `error` is a fixed English identifier β branch on it. `message` and |
| 74 | `op` are only on some of them. |
| 75 | |
| 76 | | Status | `error` | What happened | |
| 77 | |--------|--------------------------|---------------| |
| 78 | | 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. | |
| 79 | | 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. | |
| 80 | | 405 | `method_not_allowed` | Right path, wrong verb. | |
| 81 | | 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. | |
| 82 | | 413 | `too_large` | The body is over 1 MiB. | |
| 83 | | 415 | `unsupported_media_type` | The body is not `application/json`. | |
| 84 | | 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. | |
| 85 | | 429 | `busy` | You already have four exports running. Wait for one to finish; nothing was started. | |
| 86 | | 500 | `internal` | Our fault. The server log has a matching line. | |
| 87 | |
| 88 | --- |
| 89 | |
| 90 | ## Endpoints |
| 91 | |
| 92 | ### `GET /api/v1/decks` β every deck you can open |
| 93 | |
| 94 | ```sh |
| 95 | curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 96 | https://slides.example/api/v1/decks |
| 97 | ``` |
| 98 | |
| 99 | ```json |
| 100 | {"decks": [ |
| 101 | {"sqid": "k7m2xq9dwb", "title": "Q3 review", "slides": 12, |
| 102 | "rung": "private", "updated_at": "2026-09-07T09:41:02Z", "seq": 34} |
| 103 | ]} |
| 104 | ``` |
| 105 | |
| 106 | Most recently edited first. `sqid` is the deck's public name and what |
| 107 | every other endpoint takes. `rung` is who can open it: `private`, |
| 108 | `granted` or `team`. `seq` is the deck's version β it moves on every |
| 109 | write and on nothing else. |
| 110 | |
| 111 | ### `POST /api/v1/decks` β make one |
| 112 | |
| 113 | ```sh |
| 114 | curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 115 | -H "Content-Type: application/json" \ |
| 116 | -d '{"title": "Q3 review", "theme": "ink"}' \ |
| 117 | https://slides.example/api/v1/decks |
| 118 | ``` |
| 119 | |
| 120 | ```json |
| 121 | {"sqid": "k7m2xq9dwb"} |
| 122 | ``` |
| 123 | |
| 124 | 201. `title` is required; `theme` is one of `paper`, `ink`, `sunset`, |
| 125 | `forest`, `slate`, `candy`, and defaults to `paper`. The deck is owned |
| 126 | by your operator and starts on the `private` rung, exactly as one made |
| 127 | from the screen does β and like one made from the screen, it opens |
| 128 | with a title slide already on it. |
| 129 | |
| 130 | ### `GET /api/v1/decks/{sqid}` β the whole document |
| 131 | |
| 132 | ```sh |
| 133 | curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 134 | https://slides.example/api/v1/decks/k7m2xq9dwb |
| 135 | ``` |
| 136 | |
| 137 | ```json |
| 138 | {"sqid": "k7m2xq9dwb", "seq": 34, |
| 139 | "meta": {"title": "Q3 review", "theme": "ink", "accent": null, |
| 140 | "fonts": null, "aspect": "16:9"}, |
| 141 | "slides": [{"id": "hq3ktna7vc2m", "layout": "flow", "template": "title", |
| 142 | "background": {"kind": "theme", "value": ""}, |
| 143 | "phone": "inherit", "transition": "none", |
| 144 | "notes": [], "blocks": [...]}], |
| 145 | "assets": {}} |
| 146 | ``` |
| 147 | |
| 148 | The answer carries `ETag: "34"` β the seq, quoted. Send it back and an |
| 149 | unchanged deck is a 304 with no body, which costs the server nothing: |
| 150 | |
| 151 | ```sh |
| 152 | curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 153 | -H 'If-None-Match: "34"' \ |
| 154 | https://slides.example/api/v1/decks/k7m2xq9dwb |
| 155 | ``` |
| 156 | |
| 157 | Poll this rather than re-fetching. It is how you tell whether anybody β |
| 158 | a person in the editor, another agent β has moved the deck since you |
| 159 | last looked. |
| 160 | |
| 161 | ### `POST /api/v1/decks/{sqid}/ops` β change it |
| 162 | |
| 163 | Everything that changes a deck goes through here, as a batch applied in |
| 164 | order inside one transaction. |
| 165 | |
| 166 | ```sh |
| 167 | curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 168 | -H "Content-Type: application/json" \ |
| 169 | -d '{"ops": [ |
| 170 | {"op": "add_slide", "layout": "flow", "template": "section"}, |
| 171 | {"op": "set_meta", "title": "Q3 review, final"} |
| 172 | ]}' \ |
| 173 | https://slides.example/api/v1/decks/k7m2xq9dwb/ops |
| 174 | ``` |
| 175 | |
| 176 | ```json |
| 177 | {"seq": 35, "ids": {"0": "hq3ktna7vc2m"}} |
| 178 | ``` |
| 179 | |
| 180 | **You do not have to invent ids.** `add_slide`, `add_block` and |
| 181 | `duplicate_slide` each need one, and if you leave it out the server |
| 182 | mints it and hands it back keyed by the operation's index β `"0"` is |
| 183 | the first operation you sent. Supply your own and it is used as given. |
| 184 | For `duplicate_slide` the returned id is the new slide's; the copied |
| 185 | blocks get ids too, and you read them back off the deck. |
| 186 | |
| 187 | **A refused batch changes nothing.** Not the good operations in front |
| 188 | of the bad one, not the seq β the whole batch is checked before the |
| 189 | first byte is written: |
| 190 | |
| 191 | ```json |
| 192 | {"error": "invalid", "op": 1, "message": "op 1: no slide \"nosuchslide\""} |
| 193 | ``` |
| 194 | |
| 195 | If two writers land at once you may get a `409 conflict`. The batch |
| 196 | applied nothing. Re-read the deck and send it again if it still makes |
| 197 | sense. |
| 198 | |
| 199 | ### `GET /api/v1/decks/{sqid}/slides/{id}.html` β look at a slide |
| 200 | |
| 201 | ```sh |
| 202 | curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 203 | 'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.html?vp=phone' |
| 204 | ``` |
| 205 | |
| 206 | One `<section>` of HTML β the same markup the viewer renders β and |
| 207 | nothing around it. `vp=phone` renders the phone layout; leave it off |
| 208 | for the desktop one. This is how you check what you built without a |
| 209 | browser. |
| 210 | |
| 211 | ### `GET /api/v1/decks/{sqid}/slides/{id}.svg` β look at it as a picture |
| 212 | |
| 213 | ```sh |
| 214 | curl -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 215 | 'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.svg' \ |
| 216 | -o slide.svg |
| 217 | ``` |
| 218 | |
| 219 | The slide as one 1920 by 1080 SVG: the same paint list the PDF is made |
| 220 | of, so what you see here is what the export will put on a page. Text is |
| 221 | `<text>` with a `<tspan>` per run at its measured position, pictures are |
| 222 | embedded as data URIs where they can be, and nothing in the file |
| 223 | fetches anything. |
| 224 | |
| 225 | `?w=` is a rendering hint and is in the `ETag`; it changes nothing about |
| 226 | the bytes today. The `ETag` is `(seq, slide, w)`, so send |
| 227 | `If-None-Match` and you will get a `304` until somebody edits the deck. |
| 228 | |
| 229 | It is not a substitute for reading the document: a picture is for |
| 230 | looking at what you made, and `GET /api/v1/decks/{sqid}` is for knowing |
| 231 | what is in it. |
| 232 | |
| 233 | ### `POST /api/v1/decks/{sqid}/export` β make a PDF |
| 234 | |
| 235 | ```sh |
| 236 | curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 237 | -H "Content-Type: application/json" -d '{"notes": true}' \ |
| 238 | https://slides.example/api/v1/decks/k7m2xq9dwb/export |
| 239 | ``` |
| 240 | |
| 241 | ```json |
| 242 | {"job": "9Qb1t0RmS3aXk7Zt2Fv8Lw", |
| 243 | "status_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw", |
| 244 | "pdf_url": "/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf"} |
| 245 | ``` |
| 246 | |
| 247 | `202`, because nothing is ready yet: an export is a job, and what comes |
| 248 | back is where to watch it and where the file will be. The body is |
| 249 | optional; `{"notes": true}` adds the speaker notes as pages of their own |
| 250 | after each slide. |
| 251 | |
| 252 | A deck over 200 slides is a `422` before anything starts. Four exports |
| 253 | running at once for one operator is a `429` β wait for one to finish. |
| 254 | |
| 255 | ### `GET /api/v1/decks/{sqid}/export/{job}` β how is it going |
| 256 | |
| 257 | ```json |
| 258 | {"state": "running", "progress": "slide 3 of 8"} |
| 259 | ``` |
| 260 | |
| 261 | ```json |
| 262 | {"state": "done", "progress": "slide 8 of 8", "warnings": [ |
| 263 | {"kind": "asset", "slide": "hq3ktna7vc2m", |
| 264 | "detail": "image \"a1b2c3d4e5f6\" is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF"}]} |
| 265 | ``` |
| 266 | |
| 267 | `state` is `running`, `done` or `failed`, and it is the word to branch |
| 268 | on. A `failed` job carries a `message` written for a person. A `done` |
| 269 | one carries `warnings`: everything worth saying about the file β a block |
| 270 | that ran past its box, a picture that could not go in, a character no |
| 271 | embedded face can draw β each naming the slide it is about. An empty |
| 272 | list means the PDF is the deck. |
| 273 | |
| 274 | Poll it, a second or two apart. `"expired": true` on a `done` job means |
| 275 | the file is no longer there; make another. This endpoint answers `done` |
| 276 | for as long as the file does β the whole hour β so it and the `.pdf` |
| 277 | below never disagree about whether an export still exists. |
| 278 | |
| 279 | ### `GET /api/v1/decks/{sqid}/export/{job}.pdf` β take the file |
| 280 | |
| 281 | ```sh |
| 282 | curl -L -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 283 | https://slides.example/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf \ |
| 284 | -o deck.pdf |
| 285 | ``` |
| 286 | |
| 287 | The bytes, as `application/pdf` β or a `302` to a presigned URL when the |
| 288 | instance has an object store, which is why the `-L` is there. |
| 289 | |
| 290 | **The file lives for an hour.** With no bucket bound it is held in the |
| 291 | instance's memory, which is also why it is capped: an export bigger than |
| 292 | the instance will hold fails with a message saying so. |
| 293 | |
| 294 | An export belongs to the operator, not to the agent that started it β so |
| 295 | a person can watch it from the screen and any of that person's agents can |
| 296 | fetch it. Another operator's job is a `404`, exactly as another |
| 297 | operator's deck is. |
| 298 | |
| 299 | ### `POST /api/v1/decks/{sqid}/present` β start a talk |
| 300 | |
| 301 | ```sh |
| 302 | curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 303 | https://slides.example/api/v1/decks/k7m2xq9dwb/present |
| 304 | ``` |
| 305 | |
| 306 | ```json |
| 307 | {"session": "p4wq7nzk2h", "code": "K7MQ2X", |
| 308 | "present_url": "https://slides.example/p/p4wq7nzk2h", |
| 309 | "notes_url": "https://slides.example/p/p4wq7nzk2h/notes?t=...", |
| 310 | "remote_url": "https://slides.example/r/K7MQ2X?t=..."} |
| 311 | ``` |
| 312 | |
| 313 | Call it again on the same deck and you get the same session back, so a |
| 314 | projector already showing `present_url` keeps showing the same talk. |
| 315 | What you do get fresh each time is the remote token, which means the |
| 316 | previous `notes_url` and `remote_url` stop working. |
| 317 | |
| 318 | Treat the three URLs differently. `present_url` is safe to put on a |
| 319 | screen in front of a room β it only watches. `notes_url` and |
| 320 | `remote_url` carry the token that *drives* the deck, so they are |
| 321 | secrets: send them to the presenter, not to the audience. |
| 322 | |
| 323 | `present_url` is a *projector* address, though, and not an audience |
| 324 | one. It lives as long as the talk and no revocation reaches it, so |
| 325 | sending it to a room is sending something you cannot take back. For an |
| 326 | audience, mint a share link and let them follow: `/s/{token}?follow=1` |
| 327 | shows the presenter's own slide behind a capability that can be |
| 328 | revoked, and everything that page then reads β its slide fragments, its |
| 329 | stream at `/s/{token}/stream`, its pictures β resolves the token again |
| 330 | on every request. Revoke the link mid-talk and it stops, immediately. |
| 331 | |
| 332 | Every command against a session is logged as `remote:{session}` rather |
| 333 | than as whoever started it. The remote token is transferable by design |
| 334 | β that is the feature β so the server cannot say who is holding it, and |
| 335 | Β§6.2's "every write names its real actor" is kept by naming the |
| 336 | capability and logging the issuer beside it. |
| 337 | |
| 338 | This JSON is the only place the pairing block exists for a session an |
| 339 | agent started. Press Present in a browser and the presenter view shows |
| 340 | the code and a QR to scan it with; that block belongs to whoever |
| 341 | started the talk, and for your session that is the agent, not the |
| 342 | person opening the page β so `notes_url` renders the talk and the |
| 343 | controls, and no code and no QR. Pass on what you were given: the |
| 344 | `code` for somebody to type into `/r/`, or `remote_url` outright. It is |
| 345 | not recoverable afterwards, and calling present again mints a fresh |
| 346 | token, which retires the URLs you are holding. |
| 347 | |
| 348 | --- |
| 349 | |
| 350 | ## The operations |
| 351 | |
| 352 | Every operation is an object with an `op` key. Ids are twelve lowercase |
| 353 | base32 characters and are minted by the server when you omit them. The |
| 354 | vocabularies: |
| 355 | |
| 356 | - **layout**: `flow`, `canvas` |
| 357 | - **template** (flow slides): `title`, `title-body`, `two-column`, |
| 358 | `section`, `image-full`, `quote`, `blank` |
| 359 | - **region** (blocks on a flow slide): `heading`, `body`, `aside`, `media` |
| 360 | - **block type**: `heading`, `text`, `list`, `quote`, `image`, |
| 361 | `columns`, `code`, `shape`, `table`, `divider`, `spacer` |
| 362 | - **background kind**: `theme`, `color`, `image` |
| 363 | - **transition**: `none`, `fade` β **slide phone**: `inherit`, `reflow` |
| 364 | - **size**: `s`, `m`, `l` |
| 365 | |
| 366 | `after` says where something goes: absent appends, `null` puts it |
| 367 | first, an id puts it after that one. A `box` is `{x, y, w, h}` as |
| 368 | fractions of the 16:9 stage. Text arrives as Markdown and is converted |
| 369 | server-side. |
| 370 | |
| 371 | Each example below is one batch, so you can paste any of them straight |
| 372 | into the `ops` body above. `$D` is the deck sqid, `$S` a slide id and |
| 373 | `$B` a block id. |
| 374 | |
| 375 | ### Deck |
| 376 | |
| 377 | **`set_meta`** β the deck's title, theme, accent and font pair. Send |
| 378 | `null` for `accent` or `fonts` to clear one. |
| 379 | |
| 380 | ```sh |
| 381 | curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" -H "Content-Type: application/json" \ |
| 382 | -d '{"ops":[{"op":"set_meta","title":"Q3 review","theme":"ink","accent":"#c0392b", |
| 383 | "fonts":{"heading":"Inter","body":"Inter"}}]}' \ |
| 384 | https://slides.example/api/v1/decks/$D/ops |
| 385 | ``` |
| 386 | |
| 387 | ### Slides |
| 388 | |
| 389 | **`add_slide`** β a new slide. `id` optional (minted and returned). |
| 390 | |
| 391 | ```sh |
| 392 | -d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body","after":null}]}' |
| 393 | ``` |
| 394 | |
| 395 | **`remove_slide`** β delete one, and everything on it. |
| 396 | |
| 397 | ```sh |
| 398 | -d '{"ops":[{"op":"remove_slide","id":"'"$S"'"}]}' |
| 399 | ``` |
| 400 | |
| 401 | **`move_slide`** β reorder. `"after": null` moves it to the front. The |
| 402 | view's `slides` come back in the new order; under the hood the slide |
| 403 | keeps its identity and gets an `order` written on it, exactly as |
| 404 | `move_block` does, so somebody typing on that slide at the same moment |
| 405 | loses nothing (spec Β§2.1, Β§2.2). |
| 406 | |
| 407 | ```sh |
| 408 | -d '{"ops":[{"op":"move_slide","id":"'"$S"'","after":null}]}' |
| 409 | ``` |
| 410 | |
| 411 | **`duplicate_slide`** β copy a slide and everything on it. `new_id` and |
| 412 | `new_block_ids` are both optional; leave them out and the server mints |
| 413 | as many block ids as the slide has. |
| 414 | |
| 415 | ```sh |
| 416 | -d '{"ops":[{"op":"duplicate_slide","id":"'"$S"'"}]}' |
| 417 | ``` |
| 418 | |
| 419 | **`set_slide`** β a slide's own settings. |
| 420 | |
| 421 | ```sh |
| 422 | -d '{"ops":[{"op":"set_slide","id":"'"$S"'","layout":"flow","template":"quote", |
| 423 | "background":{"kind":"color","value":"#101418"}, |
| 424 | "phone":"reflow","transition":"fade"}]}' |
| 425 | ``` |
| 426 | |
| 427 | **`set_notes`** β the speaker notes, as Markdown. |
| 428 | |
| 429 | ```sh |
| 430 | -d '{"ops":[{"op":"set_notes","id":"'"$S"'","markdown":"Pause here. Ask about hiring."}]}' |
| 431 | ``` |
| 432 | |
| 433 | ### Blocks |
| 434 | |
| 435 | **`add_block`** β put something on a slide. `id` optional. On a flow |
| 436 | slide give a `region`; on a canvas slide give a `box` and optionally |
| 437 | `z`. |
| 438 | |
| 439 | ```sh |
| 440 | -d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"heading", |
| 441 | "region":"heading","props":{"level":1},"markdown":"Revenue"}]}' |
| 442 | ``` |
| 443 | |
| 444 | ```sh |
| 445 | -d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"image", |
| 446 | "box":{"x":0.1,"y":0.2,"w":0.5,"h":0.4},"z":2, |
| 447 | "props":{"asset":"a1"}}]}' |
| 448 | ``` |
| 449 | |
| 450 | **`remove_block`** β take one off. |
| 451 | |
| 452 | ```sh |
| 453 | -d '{"ops":[{"op":"remove_block","slide":"'"$S"'","id":"'"$B"'"}]}' |
| 454 | ``` |
| 455 | |
| 456 | **`move_block`** β reorder within its slide. The view's `blocks` come |
| 457 | back in the new order; under the hood the block keeps its identity and |
| 458 | gets an `order` written on it, so somebody typing into it at the same |
| 459 | moment loses nothing (spec Β§2.2). |
| 460 | |
| 461 | ```sh |
| 462 | -d '{"ops":[{"op":"move_block","slide":"'"$S"'","id":"'"$B"'","after":null}]}' |
| 463 | ``` |
| 464 | |
| 465 | A `columns` block's `props.children` names blocks on the same slide, |
| 466 | each at most once and each in at most one `children` list on the slide, |
| 467 | and never a block the columns block already sits under (spec Β§2.4). An |
| 468 | op that would share a child or close a cycle is refused with a message |
| 469 | saying which id. |
| 470 | |
| 471 | **`set_block`** β its props, region, box or z. `null` for `region` or |
| 472 | `box` clears that one. |
| 473 | |
| 474 | ```sh |
| 475 | -d '{"ops":[{"op":"set_block","slide":"'"$S"'","id":"'"$B"'", |
| 476 | "props":{"level":2},"region":"body","box":null,"z":1}]}' |
| 477 | ``` |
| 478 | |
| 479 | **`set_text`** β replace the words in a text-bearing block (`heading`, |
| 480 | `text`, `list`, `quote`, `code`, `shape`). |
| 481 | |
| 482 | ```sh |
| 483 | -d '{"ops":[{"op":"set_text","slide":"'"$S"'","id":"'"$B"'", |
| 484 | "markdown":"- Up 14%\n- Two new regions"}]}' |
| 485 | ``` |
| 486 | |
| 487 | **`set_phone`** β a block's phone layer: additions to the desktop |
| 488 | layout, never a replacement for it. |
| 489 | |
| 490 | ```sh |
| 491 | -d '{"ops":[{"op":"set_phone","slide":"'"$S"'","id":"'"$B"'", |
| 492 | "phone":{"hidden":false,"order":0,"size":"l"}}]}' |
| 493 | ``` |
| 494 | |
| 495 | ### Assets |
| 496 | |
| 497 | **`register_asset`** β name an image the deck can use. The bytes go up |
| 498 | separately (see "Getting the bytes there" below); this records the |
| 499 | result. `ref` is the object store's address for those bytes β a hex |
| 500 | SHA-256, because the store is content-addressed. |
| 501 | |
| 502 | ```sh |
| 503 | -d '{"ops":[{"op":"register_asset","id":"a1", |
| 504 | "ref":"9f2c1e...ab","name":"roadmap.png","width":1600,"height":900, |
| 505 | "alt":"Three phases, each with a date"}]}' |
| 506 | ``` |
| 507 | |
| 508 | **`remove_asset`** β drop the registration. The bytes stay in the store, |
| 509 | which is content-addressed and shared. |
| 510 | |
| 511 | ```sh |
| 512 | -d '{"ops":[{"op":"remove_asset","id":"a1"}]}' |
| 513 | ``` |
| 514 | |
| 515 | --- |
| 516 | |
| 517 | ## Getting the bytes there |
| 518 | |
| 519 | Images never travel through this API. They go browser-to-bucket, and |
| 520 | three endpoints on the *signed-in* surface arrange it. They take a |
| 521 | session cookie rather than a bearer token, because they are what the |
| 522 | editor's own upload uses. |
| 523 | |
| 524 | **An agent cannot add a picture today.** It can name a ref in a |
| 525 | `register_asset` op, and the document will hold it, but no surface will |
| 526 | serve those bytes: serving requires a verification row, and a |
| 527 | verification row is only ever written by the registration endpoint |
| 528 | below, for an upload it signed itself. Naming an address is not a way |
| 529 | to publish one. A bearer-authenticated upload is the obvious next |
| 530 | thing to build and is not built. |
| 531 | |
| 532 | Limits, on all three: 20 MiB per image, and PNG, JPEG, WebP or SVG. |
| 533 | |
| 534 | ### `POST /d/{sqid}/uploads` β where do these bytes go |
| 535 | |
| 536 | ```json |
| 537 | {"name": "roadmap.png", "size": 184320, "type": "image/png", "hash": "9f2c1e...ab"} |
| 538 | ``` |
| 539 | |
| 540 | `hash` is the file's SHA-256, computed by the client. Nothing is |
| 541 | addressed by it. It is recorded, and on the development path β where |
| 542 | this app receives the PUT and hashes the bytes anyway β a disagreement |
| 543 | is reported as `hash_mismatch`; on a real bucket it is not consulted at |
| 544 | all. The address the bytes go to is the app's own, always. |
| 545 | |
| 546 | The answer is where to PUT and what header to send: |
| 547 | |
| 548 | ```json |
| 549 | {"ref": "4d0a77...e1", |
| 550 | "url": "https://bucket.example/4d0a77...e1?X-Amz-Signature=...", |
| 551 | "headers": {"Content-Type": "image/png"}} |
| 552 | ``` |
| 553 | |
| 554 | **That `ref` is a staging address, not the address the picture will be |
| 555 | served from.** It is minted here, once, for this deck and this |
| 556 | principal, and it is the only address this app will ever sign a PUT |
| 557 | for. Two things follow, and both are the point: |
| 558 | |
| 559 | - A caller cannot ask for a PUT to an address something is already |
| 560 | served from, in this deck or any other. A presigned PUT is |
| 561 | unconditional, so an address a browser can name is an address a |
| 562 | browser can overwrite β which is how, before this, replacement bytes |
| 563 | could change a registered picture with no document update anywhere. |
| 564 | - Registration will only look at bytes staged this way. Knowing a ref |
| 565 | is not a way to get it registered; see the next endpoint. |
| 566 | |
| 567 | Sign twice for the same file and you get two staging addresses. Ask for |
| 568 | one and never use it and nothing happens: a staged upload nobody |
| 569 | registered within two hours stops being registrable. |
| 570 | |
| 571 | With no object store bound it answers `503` and |
| 572 | `{"error": "no_store", "message": "β¦"}` instead. That is the honest |
| 573 | answer, not a fallback β there is nowhere for the bytes to go. |
| 574 | |
| 575 | In development the bound store is a directory (`blobs.Dir`), which has |
| 576 | nothing to presign, so `url` comes back as a same-origin |
| 577 | `/d/{sqid}/uploads/{ref}` and this app receives the PUT itself. That |
| 578 | path resolves the same receipt before it reads a byte, so it is not a |
| 579 | way round the staging rule either. On a real bucket it answers `404`: |
| 580 | 20 MiB does not go through an app process that has one small instance's |
| 581 | memory. |
| 582 | |
| 583 | ### `POST /d/{sqid}/assets` β what actually landed |
| 584 | |
| 585 | ```json |
| 586 | {"ref": "4d0a77...e1", "name": "roadmap.png", "width": 1600, "height": 900, |
| 587 | "alt": "Three phases, each with a date", "decorative": false} |
| 588 | ``` |
| 589 | |
| 590 | `ref` here is the **staging address** the sign endpoint answered with, |
| 591 | and nothing else is accepted. This endpoint resolves it to the upload |
| 592 | receipt this app wrote when it signed the PUT: same deck, same |
| 593 | principal, not already registered, not older than two hours. Anything |
| 594 | else β a ref from another deck, a ref you happen to know, a receipt |
| 595 | somebody else staged, a receipt already spent β is `404` |
| 596 | `not_found`, the same answer an address holding nothing gets. |
| 597 | |
| 598 | That matters more than it looks. A ref is a content address, so knowing |
| 599 | one is knowing the SHA-256 of somebody's file, and this endpoint writes |
| 600 | the verification row that `GET /d/{sqid}/assets/{ref}` requires. If a |
| 601 | ref alone were enough, knowing one would be permission to read it. |
| 602 | |
| 603 | The server then reads the staged object, sniffs the bytes (a file that |
| 604 | *says* PNG and is not one is refused here, which is the only place the |
| 605 | bytes are ever looked at), sanitises an SVG β scripts, event handlers |
| 606 | and foreign HTML stripped; anything still pointing outside the document |
| 607 | refused β and **puts what it read back as a final object addressed by |
| 608 | its own SHA-256**. That final address is what the deck refers to, and |
| 609 | no browser has ever been handed a PUT for it. Alternative text is |
| 610 | required unless `decorative` is true (Β§2.4). |
| 611 | |
| 612 | It answers `200` with the record to register: |
| 613 | |
| 614 | ```json |
| 615 | {"id": "a1b2c3d4e5f6", "ref": "9f2c1e...ab", "name": "roadmap.png", |
| 616 | "width": 1600, "height": 900, "alt": "Three phases, each with a date"} |
| 617 | ``` |
| 618 | |
| 619 | **The `ref` that comes back is never the one that went in.** The one |
| 620 | that went in was a staging address; this one is the hash of the bytes |
| 621 | the server read, which for a sanitised SVG is not even the hash of what |
| 622 | was uploaded. Register what came back. |
| 623 | |
| 624 | It does not write the document. The client applies `register_asset` |
| 625 | with this record itself, so an upload reaches the deck through the same |
| 626 | op path, the same update log and the same undo step as every other |
| 627 | change. |
| 628 | |
| 629 | Refusals are `{"error": "...", "message": "..."}` β `no_store`, |
| 630 | `too_large`, `not_an_image`, `unsafe_svg`, `alt_required`, `invalid`, |
| 631 | `not_found`. (`unsupported_type` and `already_verified` are the sign |
| 632 | endpoint's own and `hash_mismatch` is the development PUT's; |
| 633 | `unsupported_type` is a *declared* type outside the four, where here |
| 634 | the bytes are what is judged and a file of the wrong kind is |
| 635 | `not_an_image`.) The `error` word is fixed |
| 636 | English for a program to branch on; the `message` is for a person and |
| 637 | follows `Accept-Language`. |
| 638 | |
| 639 | Registering also records that the ref was verified for this deck, and |
| 640 | `GET /d/{sqid}/assets/{ref}` requires that record as well as the deck's |
| 641 | own `assets` map: a `register_asset` op naming a ref that never went |
| 642 | through this endpoint is accepted by the document and the picture is |
| 643 | never served. |
| 644 | |
| 645 | ### `GET /d/{sqid}/assets/{ref}` β the bytes back |
| 646 | |
| 647 | A `302` to a presigned GET (one hour), or the file itself where the |
| 648 | store cannot presign. Two more routes serve the same bytes to the |
| 649 | surfaces that have no session: `/s/{token}/assets/{ref}` for a share |
| 650 | link and `/p/{session}/assets/{ref}` while presenting. The renderer |
| 651 | writes whichever base belongs to the page it is drawing. |
| 652 | |
| 653 | Every one of them checks the ref against **that deck's** `assets` map |
| 654 | before serving a byte. A ref is a content address, so knowing one is |
| 655 | knowing the SHA-256 of somebody's file; without that check any |
| 656 | capability for any deck would fetch any object in the bucket. |
| 657 | |
| 658 | ### For the operator: CORS on the bucket |
| 659 | |
| 660 | The presigned PUT is made by the browser, cross-origin, straight to the |
| 661 | object store. That only works if the bucket allows it, and nothing in |
| 662 | this app can arrange it for you. Configure the store's CORS policy to |
| 663 | allow `PUT` from this instance's origin, with the `Content-Type` header, |
| 664 | and expose `ETag`. Without it uploads fail in the browser with no useful |
| 665 | error β the request never reaches this app, so there is nothing here to |
| 666 | log. |
| 667 | |
| 668 | Development needs none of this: `blobs.Dir` has no presigning, so the |
| 669 | PUT is same-origin. |
| 670 | |
| 671 | --- |
| 672 | |
| 673 | ## The live-sync wire |
| 674 | |
| 675 | Four more routes on the signed-in surface, which is what the browser |
| 676 | editor is built on. You do not need them β `/api/v1/decks/{sqid}/ops` |
| 677 | is the supported way for a program to write, and polling the deck with |
| 678 | `If-None-Match` is the supported way to watch. They are documented |
| 679 | because they are how the same deck is edited by a person while your |
| 680 | agent is holding it, and because the framing is easy to get subtly |
| 681 | wrong if you ever do speak it. They take a session cookie, not a bearer, |
| 682 | and the two POSTs among them sit behind the same origin check every |
| 683 | other screen does β see "A note on CSRF" at the foot of this file. |
| 684 | |
| 685 | The document underneath is a Yjs document. `POST /ops` is a batch of |
| 686 | operations the server turns into one Yjs update; these routes carry |
| 687 | Yjs updates directly. Both end up as rows in the same log, in one |
| 688 | order, each stamped with the actor that wrote it. |
| 689 | |
| 690 | ### `GET /d/{sqid}/updates?since=N` β the bytes after seq N |
| 691 | |
| 692 | `application/octet-stream`. Every update after seq `N`, in seq order, |
| 693 | each one framed as a **4-byte big-endian length followed by that many |
| 694 | bytes**. There is no other structure β no header, no trailer, no count. |
| 695 | |
| 696 | `Slides-Seq` on the response is the highest seq in the body, or `N` |
| 697 | itself when the body is empty. Take your cursor from that header and |
| 698 | never from a separate query: it is derived from the bytes you were |
| 699 | actually handed, so a cursor read from it can never be ahead of what |
| 700 | you received. |
| 701 | |
| 702 | At `since=0` the answer may begin from a periodic snapshot rather than |
| 703 | from the first update ever written, in which case `Slides-Snapshot: |
| 704 | <seq>` says which seq that first frame brings you to. A snapshot is |
| 705 | derived data β it is a shortcut, never the truth β so a deck opens |
| 706 | identically with it and without it. |
| 707 | |
| 708 | A `since` that is missing, negative or not a number is a `400`. |
| 709 | |
| 710 | ### `POST /d/{sqid}/updates` β one update |
| 711 | |
| 712 | The body is one Yjs v1 update, 1 MiB at most. The answer is `204` with |
| 713 | `Slides-Seq` for the row it wrote. Then it is broadcast, so every other |
| 714 | open tab learns about it. |
| 715 | |
| 716 | A `422` here means the core read your bytes and refused them, which is |
| 717 | an ordinary answer rather than a server fault: these arrive from |
| 718 | browsers. A `413` is the size cap. A `409` means two writers landed at |
| 719 | once, twice β reread and decide what your update now means. |
| 720 | |
| 721 | ### `GET /d/{sqid}/stream` β server-sent events |
| 722 | |
| 723 | ``` |
| 724 | event: update |
| 725 | data: 41 |
| 726 | |
| 727 | event: presence |
| 728 | data: [{"client":"β¦","name":"Ana","colour":"#6b4","slide":"β¦","block":"β¦"}] |
| 729 | |
| 730 | : ping |
| 731 | ``` |
| 732 | |
| 733 | One of each on connect, a `: ping` every 15 seconds, and the connection |
| 734 | closes after five minutes for you to reopen. |
| 735 | |
| 736 | **`update` carries a seq and no bytes.** It is advisory β "there is |
| 737 | something at least this new" β and the bytes come from `GET /updates`. |
| 738 | `presence` carries the whole set every time, never a delta, so a |
| 739 | subscriber that falls behind is dropped and reconnects rather than |
| 740 | having events silently skipped. |
| 741 | |
| 742 | ### `POST /d/{sqid}/presence` β where you are |
| 743 | |
| 744 | ```json |
| 745 | {"client": "9f3cβ¦", "slide": "hq3ktna7vc2m", "block": "blkheading22"} |
| 746 | ``` |
| 747 | |
| 748 | `client` identifies the tab, not the person, and is required. Post it |
| 749 | every 20 seconds and whenever the person moves; two missed heartbeats |
| 750 | and the server drops you from the set. |
| 751 | |
| 752 | The name and the colour are **stamped by the server** from your user |
| 753 | row. Sending your own does nothing β otherwise presence would be a |
| 754 | place to write somebody else's name onto somebody else's screen. |
| 755 | Presence is never document state: it lives in memory, reaches no log, |
| 756 | is in no snapshot, and is gone when the process restarts. |
| 757 | |
| 758 | ### One note if you write Yjs updates by hand |
| 759 | |
| 760 | Text is an `XmlFragment` of `<paragraph>` elements whose formatting |
| 761 | attributes are the marks, and the marks are named after the attributes: |
| 762 | `b`, `i`, `c`, `h`, `href`. **`href` is stored as the one-key map |
| 763 | `{"href": "https://β¦"}`, not as a plain string** (spec Β§1 ruling 12). |
| 764 | That shape is y-prosemirror's, and it is not cosmetic: a plain string |
| 765 | throws inside its reader, and it deletes the text node when a read |
| 766 | throws β so the wrong shape loses the words, not just the link. |
| 767 | |
| 768 | Readers on both sides are tolerant where writers are exact. `b`, `i`, |
| 769 | `c` and `h` are written `true` and read as set on any truthy value; an |
| 770 | `href` is read as either the map or a plain string. The deck's JSON |
| 771 | view β what `GET /api/v1/decks/{sqid}` hands you β flattens all of that |
| 772 | into runs with a plain `href` string, so none of this reaches you |
| 773 | unless you are writing updates yourself. |
| 774 | |
| 775 | The gate on `POST /d/{sqid}/updates` checks the text you leave behind, |
| 776 | not the view of it (spec Β§2.6): a formatting attribute outside those |
| 777 | five, an element other than `<paragraph>` at the top of a text, or an |
| 778 | element or fragment inside a paragraph is a `422`, and nothing is |
| 779 | stored. The view would have read all of those; the editor would have |
| 780 | deleted the text on binding, which is why the check is on the |
| 781 | document. |
| 782 | |
| 783 | Block order is a key, not the array. A slide's `blocks` array is the |
| 784 | set of its blocks; `order` on each block map (a number, array index |
| 785 | when absent) is the reading order, and the view sorts by it (spec |
| 786 | Β§2.2). Reorder by writing `order`, never by deleting and re-inserting a |
| 787 | block map β the second loses whatever another peer typed into it in the |
| 788 | meantime. |
| 789 | |
| 790 | --- |
| 791 | |
| 792 | ## A worked example |
| 793 | |
| 794 | Build a two-slide deck from nothing: |
| 795 | |
| 796 | ```sh |
| 797 | export SLIDES_TOKEN=sl_... |
| 798 | export API=https://slides.example/api/v1 |
| 799 | |
| 800 | D=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 801 | -H "Content-Type: application/json" \ |
| 802 | -d '{"title":"Q3 review","theme":"ink"}' $API/decks | jq -r .sqid) |
| 803 | |
| 804 | S=$(curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 805 | -H "Content-Type: application/json" \ |
| 806 | -d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body"}]}' \ |
| 807 | $API/decks/$D/ops | jq -r '.ids["0"]') |
| 808 | |
| 809 | curl -sX POST -H "Authorization: Bearer $SLIDES_TOKEN" \ |
| 810 | -H "Content-Type: application/json" \ |
| 811 | -d '{"ops":[ |
| 812 | {"op":"add_block","slide":"'"$S"'","type":"heading","region":"heading", |
| 813 | "props":{"level":1},"markdown":"Revenue"}, |
| 814 | {"op":"add_block","slide":"'"$S"'","type":"list","region":"body", |
| 815 | "markdown":"- Up 14%\n- Two new regions"}, |
| 816 | {"op":"set_notes","id":"'"$S"'","markdown":"Pause on the second bullet."} |
| 817 | ]}' $API/decks/$D/ops |
| 818 | |
| 819 | # Look at what you made. |
| 820 | curl -s -H "Authorization: Bearer $SLIDES_TOKEN" "$API/decks/$D/slides/$S.html" |
| 821 | ``` |
| 822 | |
| 823 | --- |
| 824 | |
| 825 | ## A note on CSRF |
| 826 | |
| 827 | The screens are protected by an origin check on every state-changing |
| 828 | request (`Sec-Fetch-Site`, `Origin`, `Referer`). A program has none of |
| 829 | those to send, and the check refuses a request that carries none of the |
| 830 | three β so `/api/v1` is mounted outside it and gated by the bearer |
| 831 | alone. |
| 832 | |
| 833 | That is not a hole. A browser never attaches an `Authorization` header |
| 834 | by itself, so a page on some other origin cannot make a request this |
| 835 | API will authenticate, whatever it does with forms or credentials. |
| 836 | Cross-site request forgery is an attack on ambient credentials β a |
| 837 | cookie the browser sends for you β and there is no ambient credential |
| 838 | here. |
| 839 | |