oficina / slides Public
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:
{"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
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
https://slides.example/api/v1/decks{"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
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{"sqid": "k7m2xq9dwb"}titleis required;themeis one ofpaper,ink,sunset,forest,slate,candy, and defaults topaper. The deck is owned by your operator and starts on theprivaterung, 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
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
https://slides.example/api/v1/decks/k7m2xq9dwb{"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:
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
-H 'If-None-Match: "34"' \
https://slides.example/api/v1/decks/k7m2xq9dwbPoll 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.
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{"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:
{"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
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
curl -H "Authorization: Bearer $SLIDES_TOKEN" \
'https://slides.example/api/v1/decks/k7m2xq9dwb/slides/hq3ktna7vc2m.svg' \
-o slide.svgThe 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
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
-H "Content-Type: application/json" -d '{"notes": true}' \
https://slides.example/api/v1/decks/k7m2xq9dwb/export{"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
{"state": "running", "progress": "slide 3 of 8"}{"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
curl -L -H "Authorization: Bearer $SLIDES_TOKEN" \
https://slides.example/api/v1/decks/k7m2xq9dwb/export/9Qb1t0RmS3aXk7Zt2Fv8Lw.pdf \
-o deck.pdfThe 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
curl -X POST -H "Authorization: Bearer $SLIDES_TOKEN" \
https://slides.example/api/v1/decks/k7m2xq9dwb/present{"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.
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/opsSlides
add_slide β a new slide. id optional (minted and returned).
-d '{"ops":[{"op":"add_slide","layout":"flow","template":"title-body","after":null}]}'remove_slide β delete one, and everything on it.
-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).
-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.
-d '{"ops":[{"op":"duplicate_slide","id":"'"$S"'"}]}'set_slide β a slide's own settings.
-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.
-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.
-d '{"ops":[{"op":"add_block","slide":"'"$S"'","type":"heading",
"region":"heading","props":{"level":1},"markdown":"Revenue"}]}'-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.
-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).
-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.
-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).
-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.
-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.
-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.
-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
{"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:
{"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
{"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:
{"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":"β¦"}]
: pingOne 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
{"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:
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.