Publish a presentation from any program, and read any shared presentation
back as text. The API is what the /present skill calls; you can call it
without the skill. Two hosts serve it, https://bisque.today and
https://bisque.cloud, and every path on this page works on both.
The whole surface is described in OpenAPI 3.1 at
/openapi.json. The same tools are available over MCP —
see the MCP page. Agents reading this site start at
/llms.txt.
Read a presentation, no sign-in
GET /api/presentations/context returns a presentation's title, its
narration transcript slide by slide with timestamps, and the context.md
its author shipped for agents. Public and unlisted presentations need no
credential.
curl -s "https://bisque.today/api/presentations/context?url=https://bisque.today/p/siderakis/claude-system-card"
{
"requestId": "…",
"presentationId": "…",
"title": "…",
"handle": "siderakis",
"slug": "claude-system-card",
"webUrl": "https://bisque.today/p/siderakis/claude-system-card",
"visibility": "public",
"transcript": [{ "slideIndex": 0, "startSec": 0, "title": "…", "text": "…" }],
"contextMd": "…"
}
Anonymous callers get 60 requests per 10 minutes per IP; send a credential for 300, and to read your own private presentations.
Get a credential
Every write needs Authorization: Bearer <credential>.
- API key (
bisque_live_…) — for scripts, CLIs, and coding agents on your machine. Create one at bisque.cloud/setup/keys, or runbisque loginand approve the pairing code in the browser. - OAuth (
bisque_oat_…) — for hosted chat products where nobody can paste a key. Dynamic client registration, PKCE, refresh, revocation.
The step-by-step for agents, in the order they run it, is
/auth.md.
Publish
Fetch the authoring format first — it is served live and never written from memory:
curl -s https://bisque.today/api/presentations/spec
Write one HTML file in that format, then post it:
curl -s -X POST https://bisque.today/api/presentations/create \
-H "Authorization: Bearer $BISQUE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html index.html '{indexHtml: $html, slug: "my-first-presentation", visibility: "unlisted"}')"
{
"requestId": "…",
"presentationId": "…",
"webUrl": "https://bisque.today/p/you/my-first-presentation",
"status": "queued",
"statusUrl": "https://bisque.today/api/presentations/status?presentationId=…"
}
The reply is immediate. Narration runs in the background; poll statusUrl
until ready is true. Post again with the same slug to republish.
| Endpoint | What it does |
|---|---|
GET /api/presentations/spec | The html-presentation/v1 format, as markdown. Public. |
GET /api/presentations/context | A presentation as text. Public for public and unlisted presentations. |
POST /api/presentations/context | Up to 20 presentations as text in one request. |
POST /api/presentations/create | Publish; Bisque narrates. Returns 202 and a status URL. dryRun: true = sandbox. |
GET /api/presentations/status | Narration and publish progress by presentationId. |
POST /api/presentations/publish-narrated | Publish with audio you synthesized locally (what /present does). API keys only. |
GET /api/oembed | oEmbed for a watch URL. |
POST /mcp | The presentations MCP server. |
POST /docs/mcp | The documentation MCP server. No credential. |
Add slides while someone watches, and wait for their answer
A live presentation is one you keep adding slides to while a person watches
it. The slides appear in their player in place: nothing reloads, and they
stay on the slide they are on. A slide can carry a survey, and you can wait
for the answer the same way you wait for a CI run. These routes take an API
key and answer at the root, https://bisque.today/v1/presentations/....
| Endpoint | What it does |
|---|---|
POST /v1/presentations | Starts a creation. Send the first slides to keep it open for more slides. |
GET /v1/presentations/{id} | The live creation as it stands, to resume it from a new session. |
PATCH /v1/presentations/{id} | Appends slides to a live creation. |
PATCH /v1/presentations/{id}/slides/{slideKey} | Gives one slide its narration, after you PUT the MP3 to its upload URL. |
POST /v1/presentations/{id}:finalize | Ends the creation. The presentation becomes an ordinary one and live is gone. |
GET /v1/presentations/{id}/surveys/{surveyId}/response?wait= | Your answer to one survey, held open up to wait seconds for it. |
Start one by posting the first slides, exactly as any creation starts:
curl -s -X POST https://bisque.today/v1/presentations \
-H "Authorization: Bearer $BISQUE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html index.html '{indexHtml: $html, slug: "office-hours", visibility: "private"}')"
{
"presentationId": "office-hours-3f9a1c",
"creationId": "…",
"webUrl": "https://bisque.today/p/you/office-hours",
"viewable": true,
"live": { "revision": 1 },
"needsAudio": ["slide-00", "slide-01"],
"reused": [],
"carriedAssets": [],
"assetUploads": [
{ "path": "assets/logo.png", "url": "…", "method": "PUT", "headers": {} }
],
"audioUploads": [
{
"slideKey": "slide-00",
"path": "audio/slide-00.mp3",
"url": "…",
"method": "PUT",
"headers": {}
}
]
}
viewable: true means the slides are on the watch page now, silent until
their narration arrives. live is only in the reply when you asked for it.
To add slides, send the whole presentation again to
PATCH /v1/presentations/{id}, with the new slides after the ones you
already sent. The body is the same as the create body. Leave out slug,
handle, org and visibility and the ones the creation started with are
used; send a different one and you get a 409 LIVE_TARGET_CHANGED. The reply
has the same shape as the create reply, with live.revision one higher. Only
slides that still need narration are in needsAudio: a slide whose narration
text did not change keeps its audio.
A live presentation only grows. Every narrated slide you already sent has to
come back first, in the same order. You may change a slide's narration text,
which puts that slide back in needsAudio. Removing, reordering or inserting
a slide before another is a 400 LIVE_APPEND_ONLY, and the message names the
first slide that moved. Other errors: 404 NO_OPEN_CREATION when nothing is
open for that id, and 409 CREATION_SUPERSEDED when another publish of the same
presentation replaced it.
Send baseRevision, the live.revision your slides were written against,
and an append from anything that fell behind is refused instead of landing
over slides it never saw. Slide keys are positions (slide-05), so two
sessions that each add "the next slide" would otherwise both pass the
append-only check, and the second would replace the first. Appends and narration uploads
take turns: one that arrives while another is being written waits for it, up
to 20 seconds, and is a 409 LIVE_APPEND_IN_PROGRESS if it is still going. A
stale revision is a 409 LIVE_REVISION_CONFLICT. Either way nothing was
written. Resume, then append after what is there. An append with no
baseRevision still waits its turn, but is not checked for staleness.
Assets an append leaves out are kept, so the slides already published keep
theirs.
While it is live, the presentation document carries
live: { revision, startedAt, agentSeenAt }. The revision goes up by one on
every append, and the player fetches the slides again each time it rises.
agentSeenAt is when an agent last appended, sent narration or waited on an
answer, and reading the presentation to resume it leaves it alone; the watch
page says the presentation is waiting for the agent to come back when it is
more than five minutes old. Finalizing removes the field.
Resume a live presentation from a new session
A live presentation outlives the session that opened it. When that session ends before finalizing, because the agent's harness exited or lost its login, the creation stays open under its id. Any session of the same account picks it up by reading it:
curl -s https://bisque.today/v1/presentations/office-hours-3f9a1c \
-H "Authorization: Bearer $BISQUE_API_KEY"
{
"presentationId": "office-hours-3f9a1c",
"webUrl": "https://bisque.today/p/you/office-hours",
"live": { "revision": 3, "startedAt": "…", "agentSeenAt": "…" },
"voice": { "voiceId": "kokoro:af_heart", "speechSpeed": 1 },
"slides": [{ "slideKey": "slide-00", "audio": "ready" }],
"indexHtml": "<!doctype html>…",
"assets": ["assets/logo.png"],
"surveys": [
{
"surveyId": "scope",
"complete": true,
"answers": { "mode": "expansion" }
}
]
}
indexHtml is every slide published so far: append after it, and send
live.revision back as baseRevision. surveys is your own answer to each
question so far, so a question already answered is not asked again. Narrate new
slides with the same voice and speed, or the earlier slides' audio goes stale.
Reading changes nothing, so any number of sessions may resume at once; the
revision decides whose append lands. A presentation you cannot change is a
404 PRESENTATION_NOT_FOUND, a finalized one is a 404 NO_OPEN_CREATION,
and one another publish replaced is a 409. The present skill wraps this as
resume --presentation <id> --dir <dir>, and takes the watch URL in place of
the id for a presentation started without one.
To wait for an answer, ask for your own response to the survey and say how long the server may hold the request:
curl -s "https://bisque.today/v1/presentations/office-hours-3f9a1c/surveys/scope/response?wait=25" \
-H "Authorization: Bearer $BISQUE_API_KEY"
{
"presentationId": "office-hours-3f9a1c",
"surveyId": "scope",
"complete": true,
"answers": { "mode": "expansion", "notes": "Start with the team plan" },
"updatedAt": "2026-09-25T18:04:11.000Z"
}
wait is in seconds, from 0 to 25, and defaults to 0, which answers at
once. The server returns the moment every required question has an answer.
If the time runs out first you get a 200 with complete: false and no
answers, so call again. The answer read back is the one you gave, signed
in, on the watch page: a live presentation has one respondent, the account
that owns it. An answer given to different wording of the question does not
count until it is given again. A presentation you cannot read, or a survey id
it does not have, is a 404.
Sandbox
Add dryRun: true to the create body and the same checks run — the
format gate, slug and org conflicts, the tier cap, the credit preflight —
against your real account, and nothing is written. The reply is a 200
with status: "validated", the webUrl a real create would publish at,
and the narration counts, so an integration can be tested end to end
without a presentation appearing on the channel. The create_presentation
MCP tool takes the same flag. Reads never need a sandbox: the context
endpoint below touches nothing and needs no credential.
curl -s -X POST https://bisque.today/api/presentations/create \
-H "Authorization: Bearer $BISQUE_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html index.html '{indexHtml: $html, slug: "smoke-test", dryRun: true}')"
Read many at once
POST /api/presentations/context (or its alias
/api/presentations/context/batch) reads up to 20 presentations in one
request. Send {"urls": [...]}, {"items": [{"url"} | {"handle","slug"}]},
or a bare JSON array of watch URLs. Each item resolves on its own — a bad
address fails its row, not the batch — and each costs one slot of the same
per-IP window as a GET.
curl -s -X POST https://bisque.today/api/presentations/context \
-H "Content-Type: application/json" \
-d '{"urls": ["https://bisque.today/p/siderakis/claude-system-card", "https://bisque.today/p/siderakis/bun-1-3"]}'
{
"requestId": "…",
"items": [
{
"ok": true,
"input": { "url": "…" },
"presentation": { "presentationId": "…", "transcript": [] }
},
{
"ok": false,
"input": { "url": "…" },
"error": { "code": "PRESENTATION_NOT_FOUND", "message": "…" }
}
]
}
List your presentations
GET /api/presentations returns your presentations newest first, 25 per
page (limit up to 100). Pass the nextCursor from one page as cursor
on the next; hasMore is false on the last page. Filter with
visibility=public|unlisted|private|org.
SDK
@bisquecloud/sdk wraps every endpoint on this page in TypeScript, with typed
results, BisqueError for failures, an Idempotency-Key on every POST, and
retries that honour Retry-After. Zero dependencies; Node 18+, Bun, Deno.
npm install @bisquecloud/sdk
import { Bisque } from "@bisquecloud/sdk";
const bisque = new Bisque({ apiKey: process.env.BISQUE_API_KEY });
const spec = await bisque.presentations.spec();
const created = await bisque.presentations.create({ indexHtml });
const status = await bisque.presentations.waitUntilReady(
created.presentationId,
);
console.log(status.webUrl);
Source: github.com/bisque-cloud/presenter/tree/main/sdk.
Retries and idempotency
Send an Idempotency-Key header (a UUID) on every POST. If the network
drops after the request went through, retry with the same key and body and
you get the first response back, marked Idempotent-Replayed: true, instead
of a second presentation. The same key with a different body is a 422
IDEMPOTENCY_KEY_REUSED. Keys last 24 hours.
Rate limits
Every response from /api/presentations/context carries RateLimit-Limit,
RateLimit-Remaining, RateLimit-Reset (seconds) and RateLimit-Policy
(60;w=600 for anonymous callers, 300;w=600 with a credential). A 429
adds Retry-After. Publishing endpoints have no fixed limit.
Versioning and deprecation
This is version 1 of the presentations API, and the version is in the URL:
every endpoint on this page also answers at /api/v1/... — for example
/api/v1/presentations/create — serving exactly what the unversioned path
serves. The versioned address is the durable one to integrate against; the
unversioned /api/... form stays as an alias. (The agent tool API is
/v1/... at the root, a separate surface.) Within a version fields are only
added, never removed or retyped. A breaking change ships
under a new path prefix (/api/v2/...) while v1 keeps serving; a retired
prefix keeps serving for at least 90 days, and
during that time its responses carry Deprecation and Sunset headers
(RFC 9745 and RFC 8594) with the dates, plus a Link header with
rel="successor-version" naming the new path. The OpenAPI document's
info.x-versioning and info.x-lifecycle state the same policy for
machines; x-lifecycle.deprecated lists every path currently in its
notice period (none today).
Errors
Every error is JSON with a stable code, a message for people, and a
requestId to quote:
{
"requestId": "…",
"error": {
"code": "INVALID_PRESENTATION_ID",
"message": "presentationId query parameter is required."
}
}
401 means the credential is missing, expired, or revoked; 429 on the
context endpoint means the per-IP limit; a 404 from any /api path that
does not exist is also JSON, with a hint pointing at this page.
Where it fits
The /present skill wraps this API: it writes the HTML,
narrates locally, and calls publish-narrated. Reach for the API directly
when you are building your own tool, or a pipeline that publishes without an
agent in the loop.