Workflows
Work on a saved project
Turn a one-off strip into an editable project you can reopen, edit, version and re-render weeks later, including durable Hosted production.
A rendered strip is images. A project is the design itself — frame order, per-locale words, styling
and, if you let it, the screenshots too. Once Tidebook 2.4 launch exists as a project, every later
render is one call with nothing attached to the conversation.
Save this as the
Tidebook 2.4 launchproject so I can reopen it, edit it and render it again without attaching the screenshots.
Expected result: ShotOps returns an openUrl; an authenticated save also returns the owned
project id, and a later read_project confirms which screenshot cells are available.
#Save the strip as a project
Call save_project with no project argument and it creates a new one. Ask for
this when you want the strip to survive the conversation. Nothing before this step saved anything: a
render returns images and reports that no project was created, alongside an offer to make one.
{
"name": "Tidebook 2.4 launch",
"screenshots": [
{ "ref": "…01_today…", "name": "01_today.png" },
{ "ref": "…02_forecast…", "name": "02_forecast.png" },
{ "ref": "…03_spots…", "name": "03_spots.png" },
{ "ref": "…04_log…", "name": "04_log.png" },
{ "ref": "…05_alerts…", "name": "05_alerts.png" }
],
"style": { "layout": "bleed", "captions": ["…"] }
}
It is fast and it never renders: it reads the strip structure and screenshot descriptors, never the image bytes.
An agent saves when you ask it to, not at the end of every job. Wanting to keep, share, reuse or keep
editing the work is the request that produces a project; asking for images, full-resolution panels or
a bundle is not, and neither is saying nothing. Pass project only when you name the existing project
the work belongs in — an agent never infers an update target from whichever project was edited most
recently.
#Link it to the App Store listing
Tidebook already ships on the App Store, so nothing about its identity needs typing. Say which app
it is and search_app_listing finds it:
The app is Tidebook, the tide forecast one. Use its real App Store details.
Each result carries the app's exact name, its seller, its icon, its bundle ID and its numeric app ID,
plus the App Store Connect languages and the iPhone devices ShotOps would apply. Confirm which result
is yours — the icon and the seller are what prove it — then pass that result's appId, or its
storeUrl, as appListing on the save:
{
"appListing": "1234567890",
"screenshots": ["…"]
}
The new project takes the app's name, bundle ID, language list and iPhone device set. Anything you
state yourself wins: a projectName, an outputs list or a locale is applied exactly as given and
the app is still linked. Passing appListing on an existing project re-links that project and
rewrites nothing else — the language list, the frames and every caption stay as they were. From then
on read_project reports the app as linkedApp.
Both calls read Apple's public listing data, the same page anyone can open. No Apple or store
credential is involved, and finding a listing proves nothing about who owns the app — the bundle ID
stays editable, and the authenticated release path verifies ownership separately. An app released in
one country only is invisible to a search of any other, so pass country when yours does not come
back.
#Claim it in the browser
save_project always returns an openUrl. Hand it back.
Signed in, or token-backed. You also get a projectId. The project is owned the moment it is
written.
Unsigned, on your own machine. The server cannot know who the owner is, so it returns
status: "pending_claim" and a claimId rather than inventing a projectId. It stages the record
and the raw source PNGs in private claim storage so the project opens whole, and the first sign-in
through that link — good for seven days — makes that person its owner. An existing project cannot be
updated this way: without the token that owns it, there is nothing to update.
#Open it again later
Every project argument takes the id or the name as the user says it, matched exactly apart from
case and spaces. So "render my Tidebook 2.4 launch project" works without anyone copying a UUID.
Omit project entirely and you get the most recently edited project, which is the right answer only
when the user actually said "my latest". A name that matches nothing, or matches two projects, comes
back refused with that account's projects listed — ask which one they meant and pass its id.
#Read before you edit
read_project is read-only and returns the whole current state:
| Field | What it answers |
|---|---|
project_file | The designer's strip: panels in their order, captionText keyed by locale then panel, locales, baseLocale, styling. |
updatedAt | Remember it. A newer value on the next read means somebody changed something. |
screenshots | Whether the project holds its own pixels, and how completely. |
versions, heldVersion | The saved look history, when the plan includes it. |
layout | Which composition template the strip is on, derived from its values — nothing stores it, so it is never stale. |
It never returns screenshot bytes. Zero-custody means it reports that pixels exist, not what they are.
screenshots.available: true is the field that decides your next call: it means
render can render this project with no screenshots argument at all.
Read it rather than inferring from filenames. Filenames help with intake and local reload; they do
not establish whether the project still has its source bytes.
#Edit and re-render
edit_project applies a concrete change and saves it: the caption words for a frame
in a locale, a new frame order, a layout or preset for every frame, caption style, background, or a
device — added, removed, duplicated, given a new screenshot, hidden, layered, posed or placed. Frames
and devices are numbered from 1, the way read_project lists them.
{
"project": "Tidebook 2.4 launch",
"order": [1, 2, 4, 3],
"captions": [{ "frame": 3, "text": "Alerts before the swell" }]
}
A caption in a locale the project does not have yet adds that locale. A value the engine cannot use is refused with the exact field and what it accepts, and nothing is saved. The edit is written against the version it read, so a Studio save made in the meantime is never overwritten; read the project again and reapply.
#Place devices across frames
Ask for one continuous scene, such as "put the forecast phone large across frames 1 and 2", and the agent can place it by calculation instead of rendering and guessing.
The frames sit side by side as one strip. Its origin is the top-left corner, x runs right, y runs down, and the unit is pixels at the project's base output panel size. For iPhone 6.9 that is 1320 × 2868 per frame, so frame 2 spans x 1320 to 2640. A placement names the top-left of the device box and either its width or its height; the device keeps its rendered proportions:
{
"project": "Tidebook 2.4 launch",
"devices": [
{ "id": "shot-2", "placement": { "coordinateSpace": "strip", "unit": "px", "x": 700, "y": 900, "height": 1800 } },
{ "id": "shot-1", "z": 1 },
{ "action": "remove", "frame": 3, "device": 2 }
]
}
- A device belongs to the frame under its centre. It paints into the neighbouring frame when it
overlaps the seam, unless its
clipToFrameis on, and the exported panels placed side by side reproduce the strip exactly. - A higher
zpaints on top. Captions always paint above every device.hiddenkeeps a device in the project but out of the render and the export. hOffset,vOffsetanddeviceSizestill place a device inside its own frame.describe_looklists the exact relation between them and strip pixels undereditProject.strip.
Every edit returns geometry: each device's box in strip pixels, the frames it reaches, its layer,
and each overlap with both boxes, the intersection, and its size. read_project returns the same
block when you pass geometry: true.
Then re-render:
{ "project": "Tidebook 2.4 launch", "output": "panels", "delivery": "urls" }
Supply screenshots only to override what the project holds. A newly supplied file uses its name and
coordinate to find the intended shot, so order does not matter and you must not pre-sort. Once it is
saved, the source is bound to that shot's stable device/locale cell: renaming or duplicating a filename
cannot redirect persisted pixels. The response reports resolvedAt and inherited per shot, so never
assume a successful render used the exact pixels you had in mind. A shot with neither a stored
screenshot nor a supplied file appears as a reported gap. A render can show the incomplete strip while
it is still being assembled; a bundle refuses until the source is supplied.
A full-resolution Hosted re-render is an addressable production operation. Keep the returned id,
poll it with production_operation, and request its result after success rather
than resubmitting the project render. Local re-renders remain synchronous.
Packaging works the same way: render({ project, output: "bundle", bundleId }) with no screenshots
zips the project's own pixels.
#Versions
Every save_look — and every Save this version in the web app — appends a
retained version and restyles the project to it, so the next render uses it. A designer who keeps
saving keeps changing what agents render; to go back to an approved design, restore that version from
the web app's version history.
What a version contains, and what Free can read of the history, is in Looks and versions.
#The screenshot cells a project keeps
A project's screenshots are not a flat list. They are a grid: one cell per shot × device family ×
locale. read_project reports it in screenshots.cells[], one row per cell, each naming the
variant requested, the resolvedAt cell actually used, and which axes were inherited.
Whether a project holds pixels at all depends on how it was saved:
| Saved from | What the record keeps |
|---|---|
Account-scoped refs (request_screenshot_upload, import_screenshot) | A manifest pointing at those uploads. The project reopens with the screens already in it, on any device the owner signs in on. |
Inline base64, or { url } | No pointer at all. The project opens empty and asks the user to re-load their own files. |
Local { path }, token-backed | The raw PNGs are uploaded as account refs after the project is created. |
Local { path }, unsigned | The same PNGs are staged privately and promoted when the claim is owned. |
A cell with no screenshot of its own inherits, and pixels inherit along a deliberately strict path: device family dominates, and the chain never wanders sideways into another family's locale cell. Ship every locale covers what that means when German is late, and Screenshots and assets covers what deleting a ref does to a project that still points at it.
If an older project has the same filename on multiple shots, ShotOps leaves that legacy source
unassigned rather than guessing. The result contract reports an ambiguous source and the remedy;
preview can show the gap, but store-ready output refuses it until the source is repaired.