Workflows

Run it in CI

Token-backed automation: refs, a preview pass, reconnectable production, deterministic results and dependency-safe cleanup.

View as Markdown

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.

  1. Mint slots

    request_screenshot_upload with count, names (the real filenames, same order) and optionally family / locale returns one { ref, uploadUrl, name, variant } per screenshot.

  2. 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 whose PUT never happened resolves to nothing and says exactly that when you try to render it.

  3. Render from refs

    Pass { "ref": "…" } entries. The filename rides the ref itself, so a later bare { ref } still carries its identity into save_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 thisOr else
The project id, not a name and never an omitted projectAn omitted project renders whichever project was edited most recently.
The look version — hold_look, or an explicit versionThe default is Follow latest, so a designer's experiment at 4pm is what CI ships at 5pm.
panelPresetId and, for a multi-device project, outputsThe defaults follow the project, which a designer can change.
Caption text from your repo's captions.<locale>.jsonNothing else is the source of shipping copy.
bundleId and the locales listThe 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.code and contract.failure.nextAction tell a failed or refused job what to do next.
  • contract.effects, cost and artifacts say 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.resolvedInput identifies 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.readiness reports 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.