---
title: "Run it in CI"
description: "Token-backed automation: refs, a preview pass, reconnectable production, deterministic results and dependency-safe cleanup."
canonical: "https://shotops.dev/docs/mcp/workflows/automation"
last_updated: "2026-09-07"
shotops_mcp_version: "0.9.6"
result_contract_version: "2.0.0"
---
# 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.

> A CI run needs the entitlement too Full-resolution renders and every form of `emit_bundle` need an account on an active Pro trial or Pro. An unauthenticated or Free-plan pipeline gets a refusal before anything renders — no files, no charge, and no partial bundle. Have the job call [`account_status`](https://shotops.dev/docs/mcp/reference/account_status) first and fail loudly on `nextStep`, rather than discovering it three minutes into a render.

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`](https://shotops.dev/docs/mcp/reference/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`](https://shotops.dev/docs/mcp/reference/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](https://shotops.dev/docs/mcp/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. |

> Note Pinning fixes the design, not the bytes. ShotOps does not promise that two renders months apart produce byte-identical PNGs — the engine and its renderer move. If your pipeline compares artifacts, compare the inputs and the reported `layout`, not file hashes.

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`](https://shotops.dev/docs/mcp/reference/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`](https://shotops.dev/docs/mcp/reference/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.

> Deletion is explicit and irreversible Ask before deleting anything. Delete only the temporary panels and zips this run created, after the terminal result has been downloaded. Keep source screenshots by retaining them through the Project; the dependency check is a safety net, not a reason to make deletion an automatic pipeline step.

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.
