Workflows
Run it in CI
Token-backed automation: refs, a preview pass, reconnectable production, deterministic results and dependency-safe cleanup.
A release pipeline can rebuild the whole Tidebook strip without a human in the loop: read the caption
files out of the repo, render, package, upload with fastlane, then delete the temporary artifacts it
created. The four things that make that safe are a token, refs, a preview pass, and a cleanup step
that knows what the project still needs.
On every pull request, preview the Tidebook strip. On a release tag, render the pinned design, package it, download it and remove only the temporary assets this run created.
Expected result: the job records one terminal result contract, downloads one deterministic release bundle and leaves project-referenced screenshots untouched.
#Authenticate with a token
A shotops_… API token is created once, in the Studio app under the account menu → API tokens,
and shown once. Store it as a CI secret. It carries no expiry: it is valid until it is revoked, which
is also the only way to turn one off.
On ShotOps Cloud, the token is the bearer credential on the MCP endpoint. On your own machine, either
sign in once with npx shotops-mcp login, or hand the process a token per run:
SHOTOPS_TOKEN=$SHOTOPS_TOKEN npx -y shotops-mcp
npx shotops-mcp whoami prints which account a token belongs to and what plan it is on — a cheap
first step in a pipeline, and the fastest way to tell a revoked token from a misconfigured one.
Never print a token into a build log, and never paste one into a client's app definition where an OAuth flow is available instead.
#Send refs, not base64
Inline base64 flows through your own tool-call arguments before it reaches ShotOps, and a full-resolution screenshot does not fit inside the 3 MB inline cap anyway. Upload once, then pass handles.
Mint slots
request_screenshot_uploadwithcount,names(the real filenames, same order) and optionallyfamily/localereturns one{ ref, uploadUrl, name, variant }per screenshot.PUT the bytes
curl -T 01_today.png "$UPLOAD_URL"for each. The bytes go straight to storage and never enter the conversation. Minting a slot moves nothing — a ref whosePUTnever happened resolves to nothing and says exactly that when you try to render it.Render from refs
Pass
{ "ref": "…" }entries. The filename rides the ref itself, so a later bare{ ref }still carries its identity intosave_project.
On ShotOps Cloud, ask for output: "urls" on anything full-resolution. The panels are uploaded under
your own prefix and you get back a ref plus a signed download URL that is good for one hour — fetch the
bytes in the job, and keep the ref for the packaging call. The local server has no storage to upload
to and always answers inline, so a local pipeline writes the base64 out to files itself and passes
those paths back to emit_bundle.
Alternatively, skip the transfer entirely: if the project's reach is "Your account", it already holds
its screenshots and render_project({ project }) needs nothing attached at all. That is the only
route with no upload step in it, and it is the one that works in clients with no shell.
#Preview first to control cost
Run the cheap pass on every change and the expensive one only on a release.
pull request → render_strip({ preview: true }) catch it here
release tag → render_strip({ output: "urls" }) then emit_bundle({ panels })
A preview is roughly a quarter resolution and small enough to come back inline. On your own machine it costs nothing at all and is unmetered on every plan; on ShotOps Cloud a full-resolution panel costs several times what a preview panel costs, and both are priced per panel — see Plans and output for the current numbers. Five panels rendered ten times during a week of copy edits is where a budget actually goes.
Two more habits keep the release job predictable:
- Keep the operation id. A paid Hosted full-resolution render is durable and may return queued or running. Persist the id beside the release job and reconnect to that operation instead of treating a client timeout or worker restart as a reason to render again.
- Re-package instead of re-rendering. Panels you already rendered are already uploaded. Fixing a
bundle id or adding a locale folder does not need new pixels.
emit_bundle({ panels })stays an immediate compose-only call.
#Deterministic output
Pin every input a render reads, or the job renders something different tomorrow.
| Pin this | Or else |
|---|---|
The project id, not a name and never an omitted project | An omitted project renders whichever project was edited most recently. |
The look version — hold_look, or an explicit version | The default is Follow latest, so a designer's experiment at 4pm is what CI ships at 5pm. |
panelPresetId and, for a multi-device project, outputs | The defaults follow the project, which a designer can change. |
Caption text from your repo's captions.<locale>.json | Nothing else is the source of shipping copy. |
bundleId and the locales list | The bundle's folder layout is built from them. |
Read the reported facts rather than assuming them: shots[].resolvedAt and shots[].inherited say
whether a cell fell back, layout says whether the composition measured clean, and note names
anything the server did that you did not ask for.
#Branch on the result contract
Every render, project and bundle reply carries contract, the machine-readable account of what
happened. Use it in automation instead of parsing prose:
contract.failure.codeandcontract.failure.nextActiontell a failed or refused job what to do next.contract.effects,costandartifactssay what changed, what was charged and what can be delivered. An artifact id is opaque; a signed URL is only a temporary download grant.contract.resolvedInputidentifies the resolved snapshot and source cells. Its fingerprint changes when the Project, Look, copy, sources or targets change; keep the snapshot id with the release record when you need to diagnose a later difference.contract.readinessreports release findings. A preview always returns them without requiring a waiver. Final output stops for fatal findings; for a waivable one, obtain the authenticated owner's explicit approval and send back that exact finding id, digest and policy version. A waiver covers one finding only and cannot be reconstructed from its text.
For a paid Hosted render, persist contract.operation.id (or the top-level operation id) before the
job yields. Poll production_operation with action: "get"; its status and progress
survive the original connection. Branch and report on progress.panelsCompleted,
progress.panelsTotal and progress.estimatedRemainingSeconds; progress.completed and
progress.total count internal scheduling items — one per panel plus a bundle item plus a result
item — so they do not match the panels that were requested. Request action: "cancel" only after
explicit operator intent.
Once the operation succeeds, action: "result" returns the original result shape and fresh signed
delivery grants without another render or another settlement. If the start response was ambiguous,
retry the same request with the same idempotencyKey so the server can recover the existing operation
instead of creating a second one.
#Clean up what you created
Hosted artifacts are private, and some inputs and outputs have a retention deadline. Read
contract.artifacts[].retainedUntil when present, download release files promptly, and keep source
screenshots by saving them to the project rather than assuming every ref has the same lifetime.
delete_assets removes unreferenced assets by the opaque ids in the result contract:
{ "assetIds": ["11111111-1111-4111-8111-111111111111"] }
It only ever touches objects under your own account's prefix, and it validates the whole batch before deleting anything, so one foreign or malformed asset refuses the call rather than leaving a half-deleted set behind. A live Project, operation, share or preview dependency refuses deletion. Repeating a completed deletion is harmless.
On the local server there is usually nothing to clean up: an ordinary local render or export writes straight to your disk and uploads nothing.