shotops-mcp 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,21 @@
1
1
  # shotops-mcp
2
2
 
3
- A hosted-ready **MCP server** that turns app screenshots into styled 3D App Store mockups,
4
- emits a `fastlane deliver`-ready bundle, and reads your saved ShotOps look for agents
5
- (ChatGPT / Claude Code / Cursor / CI). It runs over the same `@engine` render spine the Studio web app
6
- and the `mockup-mcp` CLI use, driving headless Chromium (Playwright).
3
+ A hosted-ready **MCP server** that makes the marketing screenshots an app store listing needs.
4
+ Give it the raw screen captures from an app and it composites each one into a styled 3D device
5
+ mockup, lays them out as a screenshot strip, sets your headlines over them, and hands back the
6
+ panels as PNGs plus a `fastlane deliver`-ready bundle. Captions are held per locale, so one strip
7
+ ships in every language you list in. It renders at App Store panel sizes and bundles for fastlane
8
+ deliver, so a Google Play listing reuses the designs rather than the exact files. Designs save as
9
+ reusable **looks** and editable **projects**, so the next release matches the last one.
10
+
11
+ Built for agents (ChatGPT / Claude Code / Cursor / CI). It runs over the same `@engine` render
12
+ spine the Studio web app and the `mockup-mcp` CLI use, driving headless Chromium (Playwright).
7
13
 
8
14
  Ships as **two tiers, one engine**: a free `npx shotops-mcp` **local stdio server** that
9
15
  renders on your own machine with no account (see [Local (free tier)](#local-free-tier---npx-shotops-mcp)
10
- below), and a **hosted** server on Fly.io with an account, saved looks, and share links (see
11
- [Hosted (Fly.io)](#hosted-flyio) below, or the public
12
- [`/connect`](https://storeframe-studio.vercel.app/connect) page in the studio app).
16
+ below), and a **hosted** server on Vercel with an account, saved looks, and share links (see
17
+ [Hosted](#hosted) below, or the public
18
+ [`/connect`](https://shotops.dev/connect) page in the studio app).
13
19
 
14
20
  ## Zero-custody
15
21
 
@@ -24,37 +30,66 @@ share links) — nothing that could reach a store on your behalf.
24
30
 
25
31
  | Tool | What it does | Key inputs |
26
32
  |------|--------------|------------|
27
- | `render_strip` | Render screenshots into mocked-up per-panel PNGs | `screenshots`, `panelPresetId?`, `clip?`, `style?` **+** `look?` (they compose) / `useSavedLook?`, `project?`, `version?`, `locale?`, `preview?`, `output?` |
28
- | `emit_bundle` | `render_strip` (or pre-rendered `panels` — refs or local `{ path }`) + a `fastlane deliver` zip (+ optional share link) | `screenshots` or `panels`, `bundleId`, `clip?`, `locale?`, `locales?` (multi-locale bundle), `projectName?`, `share?`, `output?` (+ the same styling params) |
29
- | `save_project` | Save the strip as an editable Studio project (structure + look only — does NOT render, so it never hits the render timeout); returns `projectId` + an `openUrl` | `screenshots`, `project?` (update in place; omit to create), `projectName?`, `panelPresetId?`, `style?` / `look?` / `useSavedLook?`, `version?`, `locale?`, `sourceDir?` |
30
- | `read_look` | Return a project's saved ShotOps look (styling only), with its latest version, the held version, and the `versions` history | `project?` (default: your most recently edited project) |
31
- | `read_project` | Return a project's FULL current state as an opaque `ProjectFile` — frame order, per-locale caption words, locale list, styling. Answers *"what did the designer change?"* (remember `updatedAt`, re-read later) | `project?` (default: your most recently edited project) |
32
- | `render_project` | Re-render a SAVED project with your OWN raw screenshots, matched to its shots **by filename** the project supplies frame order, captions + styling, so the result reproduces the designer's strip | `project?`, `screenshots` (a FLAT list, each with its original filename as `name`), `locale?`, `preview?`, `output?` |
33
+ | `render_strip` | Preferred focused render: turn raw screenshots into mocked-up per-panel PNGs without creating or updating a project | `screenshots`, `panelPresetId?`, `clip?`, `style?` **+** `look?` (they compose) / `useSavedLook?`, `project?`, `version?`, `locale?`, `preview?`, `output?` |
34
+ | `emit_bundle` | `render_strip` (or pre-rendered `panels` — refs or local `{ path }`, or a saved `project`'s own screenshots) + a `fastlane deliver` zip (+ optional share link) | `screenshots` or `panels` or `project` (bundles what that project holds), `bundleId`, `clip?`, `outputs?` (target devices — one bundle for several sizes; omit and a `project`'s saved outputs are used), `locale?`, `locales?` (multi-locale bundle), `projectName?`, `share?`, `output?` (+ the same styling params) |
35
+ | `save_project` | Save the strip as an editable Studio project (structure + look only — does NOT render, so it never hits the render timeout); always returns an `openUrl`. Authenticated saves also return `projectId`; unsigned local saves return a seven-day pending claim the user owns after opening the link and signing in | `screenshots`, `project?` (update in place only when explicitly targeted and authenticated; omit to create a new project or pending claim), `projectName?`, `outputs?`, `panelPresetId?`, `style?` / `look?` / `useSavedLook?`, `version?`, `locale?`, `sourceDir?` |
36
+ | `read_look` | Return a project's saved ShotOps look (styling only), with its latest version, the held version, the `versions` history, and the `layout` template it is composed on (derived from its values, never stored) | `project?` (default: your most recently edited project) |
37
+ | `read_project` | Return a project's FULL current state as an opaque `ProjectFile` — frame order, per-locale caption words, locale list, styling plus the derived `layout` and a `screenshots.cells` report for every shot × target-family × locale. Each cell says whether pixels resolve, where they resolved from, and which axes inherited | `project?` (default: your most recently edited project) |
38
+ | `render_project` | Render a SAVED project. By default it renders the screenshots **the project already holds** — pass none at all, no upload needed into **every supported device the project targets**. Pixels resolve independently for each device family + locale through the shared manifest; genuine fallback is reported in `shots[].inherited` and `note` | `project?`, `screenshots?` (**optional** — omit to use what the project holds; else a FLAT list matched by original `name`, with optional `variant: { family?, locale? }` per cell), `outputs?` (override the project's saved devices for this call), `locale?`, `preview?`, `output?` |
33
39
  | `save_look` | Persist a composed look on a project (styling only — appends a new version) | `look`, `project?`, `sourceName?` |
34
40
  | `hold_look` | Hold which saved version agents render by default (replaces pinning) — the version must exist (`read_look` `versions`) | `version`, `project?` |
35
41
  | `release_look` | Clear the held version — agents Follow latest (render the newest saved look) | `project?` |
36
- | `describe_look` | The styling field catalog + defaults, for authoring from scratch | *(none)* |
37
- | `request_screenshot_upload` | Mint signed upload slots for real screenshots | `count` (1–10), `names?`, `locale?` (a per-locale batch tag) |
38
- | `import_screenshot` | Import a PNG attached in ChatGPT and return a render-ready ref | `file`, `name?`, `locale?` |
42
+ | `describe_look` | Five named design presets, two named layout templates, the user-intake script, and the full styling field catalog + defaults | *(none)* |
43
+ | `request_screenshot_upload` | Mint signed upload slots for real screenshots; every returned slot carries its semantic screenshot `variant` | `count` (1–10), `names?`, `family?`, `locale?` |
44
+ | `import_screenshot` | Get screenshots INTO ShotOps several per call, each from one named source (`{ url }`, `{ file }`, or local-stdio `{ path }`), returning a render-ready ref per entry in input order | `screenshots` (1–10 source entries), `file?` (the ChatGPT top-level attachment), `name?`, `locale?` |
39
45
  | `delete_assets` | Permanently delete private uploaded screenshots, rendered panels, or generated bundles owned by the signed-in account | `refs` (1–50 ShotOps refs) |
40
46
 
41
- - **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55`,
42
- `ipad13`, `ipad129`. This enum is a hand-kept copy of `mockup-engine/appstore.ts`'s
43
- `PANEL_PRESETS` (see `shotops-mcp/src/schemas.ts`'s `PANEL_PRESET_IDS`/`PANEL_DIMENSIONS`)
44
- when Apple revises required sizes, add the preset in the engine first, then mirror it
45
- here.
47
+ - **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55`.
48
+ **iPhone sizes only** the iPad presets are deliberately not offered, because no iPad device
49
+ model exists yet and rendering one would put an iPhone body in an iPad-shaped canvas. The
50
+ accepted set is `RENDERABLE_PANEL_PRESET_IDS` in `shotops-mcp/src/panelCatalog.ts`, a subset of
51
+ the hand-kept `PANEL_PRESET_IDS` copy of `mockup-engine/appstore.ts`'s `PANEL_PRESETS` — when
52
+ Apple revises required sizes, add the preset in the engine first, then mirror it here.
46
53
  - **`style`** — the structured styling input (call `describe_look` for the full catalog):
47
- `{ shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**; `captions`
48
- is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR an **array
49
- of caption layers** stacked on that panel (headline + subline + …). Each layer's `text` (and a
50
- single caption's `subtitle`) is per-RENDER input (never stored — a look carries styling only).
51
- Captions auto-layout above the device so they never collide with the phone.
54
+ `{ layout?, shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**;
55
+ `captions` is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR
56
+ an **array of caption layers** stacked on that panel (headline + subline + …). Each layer's
57
+ `text` (and a single caption's `subtitle`) is per-RENDER input (never stored — a look carries
58
+ styling only). Captions auto-layout in a band at the top of the panel, and the device does NOT
59
+ move to make room.
60
+ - **`style.layout`** — the panel **composition** in one word: `"standard"` or `"bleed"`. It settles
61
+ the headline's reserved region *and* the device size + position together, which is three numbers
62
+ that are not independent of each other. `standard` (the shipped default) puts the whole device
63
+ under a 2-line region — about 36 characters — and crops nothing; `bleed` reserves 4 lines (~72
64
+ characters) and runs the device 10% off the bottom edge. The region is **reserved, not fitted**:
65
+ it is held at full size whether or not the headline fills it, which is what makes every panel in
66
+ a swiped set land on the same line. **Precedence** — the template expands *first*, then any
67
+ `shotLook.phoneHeight`/`hOffset`/`vOffset` or `captions[].sizePt`/`maxWidth` you also pass
68
+ **overrides** it (`{ layout: "bleed", shotLook: { vOffset: "45" } }` is "bleed, but a bit lower").
69
+ A pure macro: nothing stores the template id, so `save_project` persists the expanded values and
70
+ `read_look` returns them unchanged. **The id is not lost, though — it is DERIVED back:**
71
+ `read_project` and `read_look` both return a `layout` report read out of the values themselves, so
72
+ it can never go stale the way a stored id would. When a template was nudged, it reports the near
73
+ miss instead of just failing to match:
74
+
75
+ ```jsonc
76
+ { "layout": "bleed" } // every panel agrees
77
+ { "layout": null, "closestLayout": "bleed", // on Bleed, with one adjustment
78
+ "differs": { "vOffset": { "is": "45", "template": "38" } } }
79
+ { "layout": null, "closestLayout": null } // nothing close
80
+ ```
81
+
82
+ It also answers per panel (`layout.panels["panel-1"]`), because a strip may mix compositions and a
83
+ multi-device panel whose devices were dragged apart is honestly on no template at all. If you set
84
+ placement by hand with no `layout`, the response `note` names the template that would have produced
85
+ it — templates are the main road, raw fields are how you nudge one.
52
86
  - **`look`** — a ShotOps look JSON exactly as `read_look` returns it, but you can also
53
- hand-author one: it gives each panel its **own** device (`shots[]`, tint/tilt/size per panel),
54
- which `style.shotLook` can't. **Validated** unknown keys are rejected (a typo won't silently
55
- render wrong). Omit for default styling.
87
+ hand-author one: it gives each screenshot/device its **own** styling through `shots[]`, including
88
+ two differently coloured devices in one panel. `shots[]` is flattened in panel order; repeat the
89
+ same `panelId` for devices sharing a panel. This is what `style.shotLook` can't do. **Validated**
90
+ — unknown keys are rejected (a typo won't silently render wrong). Omit for default styling.
56
91
  - **`style` + `look` compose** — every real App Store strip needs BOTH a different device per
57
- panel AND captions, so pass them **together**: the `look` supplies the per-panel devices +
92
+ shot AND captions, so pass them **together**: the `look` supplies the per-device styling +
58
93
  background, `style.captions` supplies the words, in one render. (Alone, `style` styles every
59
94
  phone identically. If you pass `style.shotLook`/`style.background` *alongside* a `look`, the look
60
95
  wins and the result carries a note.) Also composes with `useSavedLook`/`version`.
@@ -93,8 +128,11 @@ variants** instead of one image:
93
128
  `fastlane/screenshots/<locale>/` folder per listed locale — `fastlane deliver` uploads every
94
129
  locale in a single run. A single-entry list behaves exactly like `locale`; omitting `locales`
95
130
  is the unchanged single-locale bundle.
96
- - `request_screenshot_upload({ count, names, locale })` tags a batch of upload slots with the
97
- locale it's for (echoed back bookkeeping only; refs are locale-agnostic).
131
+ - `request_screenshot_upload({ count, names, family?, locale? })` returns each slot as
132
+ `{ ref, uploadUrl, name?, variant }`. For `render_project`, pass that `variant` through beside
133
+ the ref: it is the device-family + locale cell the screenshot overrides, not a bookkeeping tag.
134
+ Each `name` must exactly match the saved shot's original filename (`frameName`); the filename
135
+ chooses the shot while `variant` chooses that shot's device-family + locale cell.
98
136
 
99
137
  **Recommended multi-locale flow — render per locale, then compose once.** Caption text is
100
138
  per-render input, so this is also the only way to get per-locale *captions* into one bundle,
@@ -154,25 +192,50 @@ context budget (or get silently truncated by a file-read tool) long before rende
154
192
 
155
193
  **Upload out-of-band instead:**
156
194
 
157
- In ChatGPT, attach the PNG and call `import_screenshot` once per file. ChatGPT supplies the
158
- temporary file parameter; ShotOps downloads and stores it server-to-server, then returns
159
- the same `{ ref }` used by the render tools. In other hosted MCP clients, use the signed-slot
160
- flow below:
195
+ **`import_screenshot` moves them all in one call.** Each entry of `screenshots` names exactly
196
+ one source, and you get one result per entry, in the order you sent them:
197
+
198
+ ```jsonc
199
+ { "screenshots": [
200
+ { "url": "https://example.com/01_home.png", "name": "01_home.png" }, // this server fetches it
201
+ { "path": "/abs/02_stats.png" }, // local stdio server ONLY
202
+ { "file": { "download_url": "…", "file_id": "…" } } // a ChatGPT attachment
203
+ ] }
204
+ ```
161
205
 
162
- 1. Call `request_screenshot_upload({ count: N })` returns `N` `{ ref, uploadUrl }` slots
206
+ Two sources in one entry is rejected (split them into two entries). A failed entry reports its
207
+ own `error` and never cancels the others — re-import just that one. In ChatGPT the attachment
208
+ arrives as the top-level `file` parameter instead of an array entry, because the Apps SDK cannot
209
+ fill a file parameter nested inside an array; you can pass both in the same call.
210
+
211
+ `{ url }` and `{ file }` need the hosted server (it stores the fetched PNG under your account);
212
+ `{ path }` needs the local stdio server (the hosted server never reads a caller-supplied
213
+ filesystem path — that would be an LFI hole). Each refusal names the door that IS open on your
214
+ tier. When the screenshots are only on the user's machine and you are on the hosted server, there
215
+ is no URL to give, so use the signed-slot flow below:
216
+
217
+ 1. Call `request_screenshot_upload({ count: N, names?, family?, locale? })` — returns `N`
218
+ `{ ref, uploadUrl, name?, variant }` slots
163
219
  under your own private prefix, plus a copy-paste `curl -T` example.
164
220
  2. `curl -T screenshot.png "<uploadUrl>"` each screenshot directly (bytes never touch this
165
221
  conversation).
166
222
  3. Pass `{ "ref": "<ref>" }` — not inline base64 — as that screenshot's entry in
167
- `render_strip`/`emit_bundle`'s `screenshots` array.
223
+ `render_strip`/`emit_bundle`'s `screenshots` array. For `render_project`, also pass the slot's
224
+ `name` and `variant`; `name` must exactly match the saved shot's original filename, and legacy
225
+ entries without a variant remain the base/fallback cell.
168
226
 
169
227
  `screenshots` entries accept these shapes: an inline base64 string (small payloads only —
170
- inline is capped and returns a clear "payload too large" error above ~8MB decoded, pointing
228
+ inline is capped and returns a clear "payload too large" error above 3MB decoded, pointing
171
229
  you back at this flow), `{ ref }` from step 1, `{ url }` (an https URL this server fetches
172
230
  itself: image/png, no redirects, ~20MB cap), `{ path }` (local stdio server only — see below),
173
231
  or `{ "locales": { "<locale>": <any of those> } }` for per-locale variants (see
174
232
  [Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles) above).
175
233
 
234
+ Every raw PNG is automatically palette-optimized before rendering (TinyPNG-style, locally inside
235
+ ShotOps): dimensions and transparency are preserved, and an already-smaller PNG is left byte-for-byte
236
+ unchanged. This happens after the input reaches the MCP, so it does **not** raise the inline request
237
+ cap — use the ref/upload flow for a large source file.
238
+
176
239
  **Output** works the same way in reverse: `render_strip`/`emit_bundle` take an `output` field
177
240
  — `"inline"` (base64), `"urls"` (uploaded under your own prefix, short-lived signed download
178
241
  URLs), or omit for auto (inline under ~200KB total, `urls` above). Use `"urls"` for
@@ -224,7 +287,7 @@ is entered in the Platform dashboard. Set it on the hosted server as
224
287
  `OPENAI_APPS_CHALLENGE_TOKEN`; the server then returns that exact value from
225
288
  `/.well-known/openai-apps-challenge`. With no token configured, the route returns 404.
226
289
  The server also exposes a synthetic, non-user PNG at
227
- `/review-fixtures/sample-app-screen.png` so reviewers can reproduce attachment and URL-input
290
+ `/review-fixtures/sample-app-screen-v2.png` so reviewers can reproduce attachment and URL-input
228
291
  tests without private fixture data or third-party hosting.
229
292
 
230
293
  ## Connect Claude Code
@@ -254,23 +317,23 @@ unzip shotops-appstore-upload.zip -d upload && cd upload
254
317
  fastlane deliver # signs in as YOU, previews, uploads screenshots to a draft — never submits
255
318
  ```
256
319
 
257
- ## Hosted (Fly.io)
320
+ ## Hosted
258
321
 
259
322
  The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:
260
323
 
261
324
  ```
262
- https://shotops-mcp.fly.dev/mcp
325
+ https://shotops.dev/mcp
263
326
  ```
264
327
 
265
328
  Nothing to install — point any MCP client at that URL with your `Authorization: Bearer
266
329
  shotops_…` header, same as local dev, just swap the base URL:
267
330
 
268
331
  ```bash
269
- claude mcp add --transport http shotops https://shotops-mcp.fly.dev/mcp \
332
+ claude mcp add --transport http shotops https://shotops.dev/mcp \
270
333
  --header "Authorization: Bearer shotops_…"
271
334
  ```
272
335
 
273
- The public [`/connect`](https://storeframe-studio.vercel.app/connect) page in the studio
336
+ The public [`/connect`](https://shotops.dev/connect) page in the studio
274
337
  app has the same instructions for Cursor/CI, plus a link to where to create a token — it's
275
338
  the one place an external agent user (not a repo collaborator) can find the connect story,
276
339
  since this repo is private.
@@ -288,7 +351,7 @@ since this repo is private.
288
351
  - **Verify after any deploy** — a green `flyctl deploy` isn't proof the server answers MCP
289
352
  calls:
290
353
  ```bash
291
- node verify-mcp.mjs https://shotops-mcp.fly.dev/mcp <a-real-shotops_-token>
354
+ node verify-mcp.mjs https://shotops.dev/mcp <a-real-shotops_-token>
292
355
  ```
293
356
  Exercises `tools/list`, `read_look`, `emit_bundle` (asserts the zip unzips to
294
357
  README/Deliverfile/screenshots), and asserts no-token / bad-token both get `401`.
@@ -299,11 +362,19 @@ since this repo is private.
299
362
  server at runtime. Vite is build/parity tooling only and is pruned from production
300
363
  dependencies; the Docker build context still includes `mockup-engine` so the harness can
301
364
  bundle the shared engine + GLB.
302
- - **VM size is `shared-cpu-2x` (2 vCPU / 2GB), not `shared-cpu-1x`** (found live, Phase 10):
303
- Chromium's software WebGL render (no GPU on Fly's shared tier) is CPU-heavy enough that on
365
+ - **VM size is `shared-cpu-2x` with 4GB and BOTH numbers are load-bearing for different
366
+ reasons.** The **memory** was raised 2gb → 4gb on 2026-07-26 (#134): a grouped multi-panel
367
+ `render_strip` drove `chrome-headless` to ~1.4gb resident, the kernel OOM-killed it, and the
368
+ server went unhealthy for minutes — `verify-mcp.mjs` could not run to completion at 2gb and now
369
+ does. The **2 vCPUs** are the older finding below; don't conflate them, and don't cut either.
370
+ - **Why 2 vCPU and not `shared-cpu-1x`** (found live, Phase 10):
371
+ Chromium's software WebGL render (no GPU on the hosted tier) is CPU-heavy enough that on
304
372
  a single shared vCPU it can starve the whole process — health checks stop responding,
305
- SIGTERM/SIGINT get ignored, and Fly force-kills the VM ("Virtual machine exited abruptly")
306
- mid-render. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't
373
+ SIGTERM/SIGINT get ignored, and the platform force-kills the machine
374
+ mid-render. **`Virtual machine exited abruptly` has TWO causes and they look identical in the
375
+ logs:** on 1 vCPU it is this CPU starvation; on 2 vCPU under load it was the #134 OOM, i.e.
376
+ MEMORY, fixed by the `4gb` above (`docs/reference/ops-runbook.md` carries that half). Check
377
+ which resource ran out before reaching for either fix. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't
307
378
  drop back to `shared-cpu-1x` to save cost — a render-triggering tool call is expected to
308
379
  occasionally saturate a whole core, and 1x has no second core to keep the process alive
309
380
  while that happens. Client callers (agents, `verify-mcp.mjs`) should still set a generous
@@ -314,7 +385,7 @@ since this repo is private.
314
385
  ## Local (free tier) — `npx shotops-mcp`
315
386
 
316
387
  The SAME server also runs as a **local stdio MCP**, entirely on your own machine — no URL, no
317
- account, no Fly cost, and no render-timeout ceiling (the hosted server's one real limit — see
388
+ account, no hosted cost, and no render-timeout ceiling (the hosted server's one real limit — see
318
389
  "Ops" above). It's the same `registerTools`/render engine as the hosted server; only the
319
390
  transport and a few account-shaped tools differ.
320
391
 
@@ -347,17 +418,23 @@ produce `output: "urls"` refs. After rendering, pass the on-disk PNGs straight t
347
418
  { "bundleId": "com.acme.app", "panels": [{ "path": "/abs/panel-01.png" }, { "path": "/abs/panel-02.png" }] }
348
419
  ```
349
420
 
350
- **What's different locally, and why:** every tool that needs an ACCOUNT to read or persist to
351
- `read_look` / `save_look` / `save_project` / `read_project` / `render_project` / share links
352
- has nothing to talk to in local mode by default, so they cleanly no-op with a "sign in" message.
353
- `render_strip` / `emit_bundle` are otherwise **fully functional** you lose nothing on rendering
354
- itself, only on persistence, which is exactly the free/paid line:
421
+ **Saving without a token:** a new `save_project` made from local `{ path }` PNGs creates a private
422
+ **pending claim**. It uploads only that saved project's byte-free record and raw source PNGs,
423
+ returns `status: "pending_claim"` plus `claimId` and `openUrl`, and deliberately returns no
424
+ `projectId` yet. Give the `openUrl` to the user: after one Google sign-in they own the project,
425
+ with its screenshots, and the link expires after 7 days. An existing `project` cannot be updated
426
+ without the token that owns it. Non-path inputs are refused rather than creating a blank claim.
427
+
428
+ Ordinary `render_strip` / `emit_bundle` calls still upload **nothing** and work fully. Other
429
+ account reads/writes (`read_look`, `save_look`, `read_project`, `render_project`, share links)
430
+ still need a token or hosted connection. A token-backed local `render_project` reads the cloud
431
+ manifest through the Studio control plane, downloads only the resolved cells, and renders locally:
355
432
 
356
- | | Local (free, `npx shotops-mcp`) | Hosted (Fly, `shotops_…` token) |
433
+ | | Local (free, `npx shotops-mcp`) | Hosted (`shotops_…` token) |
357
434
  |---|---|---|
358
- | Render compute | **your machine** ($0, no timeout) | our Fly machine |
435
+ | Render compute | **your machine** ($0, no timeout) | our servers |
359
436
  | Auth | none (the process runs as you) | account + API token |
360
- | Saved looks / editable projects / project read+re-render / share links | opt-in via a token (below) share links still need a direct hosted connection | ✅ |
437
+ | Saved looks / editable projects / project read+re-render / share links | new project: pending claim; existing/account state: token (below); share links: hosted | ✅ |
361
438
  | Screenshot input | local `{ path }` off disk | `request_screenshot_upload` → `{ ref }` |
362
439
 
363
440
  ### Optional: bridge a local render into your hosted account
@@ -365,8 +442,8 @@ itself, only on persistence, which is exactly the free/paid line:
365
442
  Pass a personal ShotOps API token (Studio → account menu → **API tokens** — the same
366
443
  `shotops_…` token the hosted server takes as a bearer) and `save_project` / `read_look` /
367
444
  `save_look` / `read_project` / `render_project` start working too — **rendering still happens
368
- locally and for free; only the small saved-project JSON (structure + look, never screenshot
369
- bytes) goes to your account:**
445
+ locally and for free.** A deliberate save also uploads its local source PNGs privately so the
446
+ project reopens with screenshots on another device; normal renders and exports never upload:
370
447
 
371
448
  ```bash
372
449
  SHOTOPS_TOKEN=shotops_… claude mcp add shotops -- npx -y shotops-mcp
@@ -411,7 +488,7 @@ shotops-mcp/
411
488
  vite.harness.config.mjs the STATIC harness build (local mode's publish prerequisite) → harness-dist/
412
489
  build.mjs esbuild bundle of src/local.ts (+ @engine/@api inlined) → dist/local.js
413
490
  Dockerfile hosted image — Playwright's Chromium base + this repo's 3 npm installs
414
- fly.toml Fly app config (scale-to-zero, /health check) deploy from repo root
491
+ fly.toml Fly app config ROLLBACK ONLY since the Vercel cutover; see #68
415
492
  verify-mcp.mjs post-deploy smoke test — real MCP round-trip against a hosted URL
416
493
  verify-mcp-local.ts local stdio smoke test (tsx src/local.ts, in-repo)
417
494
  static-parity.ts pixel-diffs the static-serve render against the Vite-dev render (must be 0)