Run View Transition Desk from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page masks the JSX, checks every ViewTransition against the skill's rules, makes the mechanical fixes itself and writes the CSS recipes the class names need, then hands it all to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans the upgraded file with the same scanner (vtreply.js).
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"view-transition-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, unit, code, facts) is missing. The page always sends all six of task, unit, code, css, carry, facts; carry is an empty string for review and css may be empty. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - inspect, then produce:
review- each of the page's findings confirmed or cleared, the skill's patterns that fit in priority order, the navigation map, up to five issues a scan cannot see on real lines, and the fixes in order.rewrite- the whole component file with the patterns implemented and the CSS to add, with what changed and what was kept, every component name unchanged.
A missing or unknown task is answered as review. Both lanes send the same six fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.
The input
| Field | Required | What |
|---|---|---|
task | yes | review or rewrite. If it is missing or neither, do review and set "task": "review". |
unit | yes | the file's path and the framework (the Next.js App Router, or React 19.3+). |
code | yes | the component file as pasted (before the page's changes). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send. |
css | send it (may be empty) | the global stylesheet as pasted; may be empty. |
carry | send it (empty for review) | rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty. |
facts | yes | the page's scan: the classes the props name and what the page's CSS to add holds, then one finding per line as id | area | severity | line | where | evidence | source | what the page did (fixed, left, or candidate). "fixed" means the page made the change the finding's fix names in its own copy (and CSS), which the person already has; the code field is still the original paste, so judge it as written. A "candidate" is something the file offers to animate. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @vercel-labs/react-view-transitions - the only standard the run applies:
- Every ViewTransition should communicate a spatial relationship or continuity. Implement every pattern that fits, in this priority order: 1 shared element (name) - "same thing, going deeper"; 2 Suspense reveal - "data loaded"; 3 list identity (a key per item) - "same items, new arrangement"; 4 state change (enter / exit) - "something appeared or disappeared"; 5 route change (page level) - "going to a new place".
- Animation style: hierarchical navigation (list to detail) and ordered sequences use type-keyed nav-forward / nav-back slides; lateral navigation (tab to tab) a bare ViewTransition (cross-fade) or default="none"; Suspense reveals string enter / exit props; revalidation default="none".
- Triggers: only startTransition, useDeferredValue and Suspense activate a VT; plain setState does not animate; flushSync skips the animation; never call document.startViewTransition yourself. addTransitionType goes inside startTransition. router.back() and the browser back button carry no types (type maps resolve to default); next/link takes transitionTypes.
- Placement: a VT only activates enter / exit when it appears before any DOM node in the subtree that mounts or unmounts - a VT unconditionally inside a div that mounts with it never enters (sibling order does not matter; the parent DOM node mounting in the same commit does); render the VT itself conditionally or make it the outermost element.
- Props: "auto", "none", a class name, or a type map { [type]: value } that must carry a default key. default="none" turns off every trigger not listed, including update and share - a named VT with default="none" and no share never morphs. Always pair enter with exit. Never a fade-out exit on a page with a shared morph.
- Names: only one VT with a given name can be mounted - unique names per item (photo-${id}); a reusable component with a named VT shown in two places at once breaks the morph. In a list, nest two layers: an outer keyed VT for list identity, an inner named VT for the shared element; no wrapper DOM node between the list and the VT; order by committed state, not useOptimistic.
- Suspense: wrap the fallback (exit) and the content (enter, default="none") in separate VTs, or a bare VT around the Suspense for a cross-fade; string props only - Suspense resolves in a separate transition with no type; an element in both fallback and content flickers - render it outside the Suspense.
- Layouts: layouts persist across navigations - directional VTs go in each page component (a DirectionalTransition wrapper), not a layout around {children}; nested VTs that mount with a parent VT skip their own enter / exit.
- Persistent chrome (header, nav) gets a viewTransitionName style plus the persistent-element CSS. CSS: the skill's recipes (fade-in / fade-out, slide-up / slide-down, nav-forward / nav-back, slide-from-right ..., morph, text-morph, scale-in / scale-out), the timing variables, and always the reduced-motion block in the global stylesheet.
- Next.js: do not install react@canary - the App Router bundles it. Without Next.js: React 19.3 or newer.
House rules
- Use only the input. Never invent a component, a route, a file or a prop the code does not hold; ViewTransition, startTransition, addTransitionType, useDeferredValue, Suspense, transitionTypes and the recipe class names above are fine. When a fix needs another file (the page a link goes to, the layout, the global stylesheet), say so in a caveat.
- The page's ids and lines are measured facts: never renumber an id. Every finding is confirmed or cleared with a reason tied to the code: a fixed one is confirmed with the page's change as the fix, or cleared when that change is wrong here; a left one or a candidate is confirmed with the hand fix, or cleared when the code shows it does not apply.
- No web links (http or https) in prose.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks in prose (the code and css fields are code: template literals such as name={
photo-${id}} belong there). Name components and props plainly (ViewTransition enter="fade-in", startTransition(() => ...)). Words from the rules above are free to use; what you never do is paste a whole sentence from this prompt. - Never write a gendered pronoun for a user; write they, the user or the person.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
review
Decide each of the page's findings, pick the patterns that fit, map the navigations, add what a scan cannot see and put the steps in order.
summary- one sentence, at most 40 words: what will not animate today, what the page already fixed, and the first step.patterns- one object per pattern that fits this file, in the priority order above:pattern(exactly one of "shared element", "suspense reveal", "list identity", "state change", "route change"),where(the component or element),why(one sentence: what it communicates here). Patterns come from the ViewTransitions already in the file (working or broken) as well as from the candidates; a confirmed Suspense candidate means a "suspense reveal", a confirmed list candidate a "list identity".[]only when nothing in the file should animate.nav_map- one string per navigation the file triggers: "from -> to | forward, back, lateral or sequential | the VT pattern" (e.g. "/photos -> /photos/[id] | forward | directional slide + shared photo morph").[]when the file triggers no navigation.findings- one object per page finding you confirm, in id order:id,widget(where it sits, e.g. "Gallery"),issue(one sentence),fix(one sentence naming one decision - never "X, or Y"; when the right choice needs another file, decide for this file and say what the other file must hold),code(the lines for that piece only, at most 8; "" when fix says it all). Two findings never get fixes that contradict each other.cleared- one object per page finding that is not a problem here, withidandwhy(one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.extra- at most 5 issues the scan cannot see, each tied to a line of the code:id(X1, X2, ...),area(placement, triggers, names, props, suspense, layout, css or a11y),line(a number),widget,issue,fix. Examples: a state update that should run in startTransition, a persistent header that needs a viewTransitionName, an element rendered in both a fallback and its content, a shared name whose target view is in another file, a link that needs transitionTypes.[]when there are none.plan- 2 to 10 short strings, the steps in order (triggers and placement first, then names and type maps, then the patterns in priority order, the CSS last), each naming the ids it covers (e.g. "Key the photo name per item and give it share (V3, V4)"). Every confirmed id appears in a step.[]only when nothing is confirmed.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "review",
"summary": "one sentence",
"patterns": [ { "pattern": "shared element", "where": "Gallery photo", "why": "one sentence" } ],
"nav_map": [ "/photos -> /photos/[id] | forward | directional slide + shared photo morph" ],
"findings": [ { "id": "V1", "widget": "the file", "issue": "one sentence", "fix": "one sentence", "code": "import { ViewTransition, startTransition } from \"react\";" } ],
"cleared": [ { "id": "V4", "why": "one sentence" } ],
"extra": [ { "id": "X1", "area": "triggers", "line": 15, "widget": "Gallery", "issue": "one sentence", "fix": "one sentence" } ],
"plan": [ "Key the photo name per item and give it share (V3, V4)" ],
"caveats": [ ]
}
The page holds it to its own facts (check() in vtreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; patterns from the skill's five, in priority order, naming the reveal and list patterns when those candidates are confirmed; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.
rewrite
Rewrite the whole component file with the patterns implemented, and write the CSS to add.
summary- one sentence, at most 40 words: what the rewrite changed and what it left for the team.code- the complete file. Keep every component, export, prop and import still used, every element and class; change what the findings name, plus every carried extra whose edit is made in this file (list it in changes; one that needs another file goes in a caveat), plus the imports a change needs. Implement every pattern the carried review picked, following the rules above. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is.css- the CSS to add to the global stylesheet: a ::view-transition rule for every class the file's ViewTransition props name that the pasted stylesheet does not already style (the skill's recipes for its own class names), the timing variables and keyframes those rules use, and the reduced-motion block unless the stylesheet has one. Never repeat a rule the stylesheet already has.changes- one object per page finding you acted on (including the ones the page already fixed, which the rewrite keeps), in id order:id,change(one sentence naming where). Carried extra ids (X1, ...) go after the page's ids.kept- one object per page finding you did not change:id,why(one sentence - the review cleared it, or it needs a file the paste does not hold). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "rewrite",
"summary": "one sentence",
"code": "the complete component file",
"css": "the CSS to add",
"changes": [ { "id": "V1", "change": "one sentence" } ],
"kept": [ { "id": "V4", "why": "one sentence" } ],
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; every component survives; the file is not shortened (at least 70% of its lines) and elides nothing; and the rewritten code is scanned again with the pasted stylesheet plus its CSS - a rule the changes claim fixed that still fires, a rule the paste never had, a class with no rule, a missing reduced-motion block, or flushSync / document.startViewTransition left in, is flagged. A component the rewrite drops or renames is flagged.
Worked examples
Three runs from the page's free samples (invented apps): the review of a photo gallery whose transitions never fire, the rewrite of the same file carrying that review, and the review of a product page with no transitions yet. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
review - A photo gallery with broken transitions
The page read it as: Animates with gaps - 2 blocker, 5 major, 1 minor; findings V1 unstable-import, V2 flush-sync, V3 list-outer, V4 none-no-share, V5 reduced-motion, V6 static-name, V7 css-missing, V8 placement.
Input (facts is 2311 characters; the first 1397 are shown):
{
"task": "review",
"unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router (React canary bundled - never install react@canary)",
"code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { unstable_ViewTransition as ViewTransition, startTransition } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport Link from \"next/link\";\n\ntype Photo = { id: string; src: string; title: string };\n\nexport function Gallery({ photos }: { photos: Photo[] }) {\n const [sort, setSort] = useState<\"new\" | \"old\">(\"new\");\n const ordered = sort === \"new\" ? photos : [...photos].reverse();\n\n function toggle() {\n flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\"));\n }\n\n return (\n <section>\n <button onClick={toggle}>Sort: {sort}</button>\n <ul className=\"grid\">\n {ordered.map((photo) => (\n <li key={photo.id}>\n <ViewTransition name=\"photo\" default=\"none\">\n <Link href={`/photos/${photo.id}`}>\n <img src={photo.src} alt={photo.title} />\n </Link>\n </ViewTransition>\n </li>\n ))}\n </ul>\n <div className=\"panel\">\n <ViewTransition enter=\"fade-in\" exit=\"fade-out\">\n <p>{ordered.length} photos</p>\n </ViewTransition>\n </div>\n </section>\n );\n}\n",
"css": ".grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }\n::view-transition-old(.fade-out) {\n animation: 150ms ease-in fade reverse;\n}\n@keyframes fade {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n",
"carry": "",
"facts": "The page's scan of 40 lines - 2 ViewTransitions, 1 named VT; 1 component (Gallery); 6 of 8 findings fixed by the page; verdict: Animates with gaps.\nClasses the VT props name: fade-in, fade-out; stylesheet pasted (9 lines); the page's CSS to add holds fade-in and the reduced-motion block.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | the file | unstable_ViewTransition is the canary name - React 19.3 and the Next.js App Router export ViewTransition. | source: import { unstable_ViewTransition as ViewTransition, startTransition } from \"react\"; | fixed\n- V2 | triggers | major | line 15 | Gallery | flushSync(...) commits synchronously, so the view transition is skipped. | source: flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\")); | fixed\n- V3 | names | major | line 24 | Gallery | A named VT inside a list item with no keyed <ViewTransition> around the item - the shared-element morph has no list-identity layer, so a reorder animates nothing. | source: <ViewTransition name=\"photo\" default=\"none\"> | left\n- V4 | props | blocker | line 24 | Gallery | A named VT with default=\"none\" and no share prop - share resolves to none, so the morph silently never fires. | source: <ViewTransition name=\"photo\" default=\"none\"> | fixed\n- V5 | a11y | minor | line 24 | Gallery | The stylesheet has no prefers-reduced-motion\n\n[... 914 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "The sort toggle and the photo morph never animate today; the page already swapped the canary import, moved the toggle to startTransition, named each photo per id with share and added the CSS; start by giving each list item a keyed ViewTransition.",
"patterns": [
{
"pattern": "shared element",
"where": "Gallery photo image",
"why": "The thumbnail morphs into the large photo on the detail view, so the person sees the same picture going deeper."
},
{
"pattern": "list identity",
"where": "Gallery photo grid (ul.grid)",
"why": "Toggling the sort reorders the same photos, so each card should glide to its new slot instead of jumping."
},
{
"pattern": "state change",
"where": "Gallery photo count panel",
"why": "The count panel fades in when photos appear and fades out when the grid empties."
}
],
"nav_map": [
"/photos -> /photos/[id] | forward | nav-forward slide in each page + shared photo morph"
],
"findings": [
{
"id": "V1",
"widget": "the file",
"issue": "The import uses unstable_ViewTransition, the canary export name, while the Next.js App Router exports ViewTransition directly.",
"fix": "Import ViewTransition and startTransition from react by their stable names, as the page did.",
"code": "import { ViewTransition, startTransition } from \"react\";"
},
{
"id": "V2",
"widget": "Gallery",
"issue": "toggle wraps setSort in flushSync, which commits synchronously and skips the view transition for the reorder.",
"fix": "Run the sort update inside startTransition and drop the flushSync import, as the page did.",
"code": "function toggle() {\n startTransition(() => setSort(sort === \"new\" ? \"old\" : \"new\"));\n}"
},
{
"id": "V3",
"widget": "Gallery",
"issue": "Each photo has only the inner named ViewTransition, so a sort change has no list-identity layer and the cards do not move.",
"fix": "Wrap each item in an outer ViewTransition keyed by photo.id, with no DOM node between the map and that VT, and keep the named VT inside it.",
"code": "{ordered.map((photo) => (\n <ViewTransition key={photo.id}>\n <li>\n ...named photo VT...\n </li>\n </ViewTransition>\n))}"
},
{
"id": "V4",
"widget": "Gallery",
"issue": "The named photo VT sets default=\"none\" without share, so share resolves to none and the morph never fires.",
"fix": "Add share=\"morph\" next to default=\"none\" so only the shared pair animates, as the page did.",
"code": "<ViewTransition name={\"photo-\" + photo.id} share=\"morph\" default=\"none\">"
},
{
"id": "V5",
"widget": "Gallery",
"issue": "The pasted stylesheet holds no prefers-reduced-motion block, so every slide and morph plays for people who asked for less motion.",
"fix": "Add the reduced-motion block to the global stylesheet, as the page did.",
"code": ""
},
{
"id": "V6",
"widget": "Gallery",
"issue": "Every photo in the map mounts a ViewTransition called photo, yet only one VT with a given name can be mounted.",
"fix": "Build the name from the photo id so each item is unique, as the page did.",
"code": "<ViewTransition name={\"photo-\" + photo.id} share=\"morph\" default=\"none\">"
},
{
"id": "V7",
"widget": "Gallery",
"issue": "The panel VT names fade-in for enter, but the stylesheet only styles fade-out, so the enter has no animation.",
"fix": "Add the fade-in recipe and the timing variables it uses to the global stylesheet, as the page did.",
"code": ""
},
{
"id": "V8",
"widget": "Gallery",
"issue": "The panel VT sits inside div.panel, which mounts and unmounts with it, so its enter and exit never activate.",
"fix": "Move the ViewTransition outside the panel div and render it conditionally on ordered.length so the VT itself appears and disappears.",
"code": "{ordered.length > 0 && (\n <ViewTransition enter=\"fade-in\" exit=\"fade-out\">\n <div className=\"panel\">\n <p>{ordered.length} photos</p>\n </div>\n </ViewTransition>\n)}"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "triggers",
"line": 25,
"widget": "Gallery",
"issue": "The photo Link carries no transition type, so the detail page cannot tell a forward step from any other navigation.",
"fix": "Pass transitionTypes={[\"nav-forward\"]} on the Link so the detail page's directional VT resolves the forward slide."
},
{
"id": "X2",
"area": "names",
"line": 24,
"widget": "Gallery",
"issue": "The photo name only morphs if the detail view mounts a VT with the same photo-id name, and that view is not in this file.",
"fix": "On the /photos/[id] page, wrap the large image in ViewTransition name={\"photo-\" + id} share=\"morph\" with the same id."
}
],
"plan": [
"Swap the canary import for ViewTransition and move the sort toggle into startTransition (V1, V2)",
"Move the panel VT outside its div and render it only while there are photos (V8)",
"Name each photo per id and give it share=\"morph\" beside default=\"none\" (V6, V4)",
"Wrap each list item in an outer ViewTransition keyed by photo.id around the named photo VT (V3)",
"Add transitionTypes to the photo Link and the matching photo name on the detail page (X1, X2)",
"Add the fade-in and morph recipes, the timing variables and the reduced-motion block to the global stylesheet (V7, V5)"
],
"caveats": [
"The matching photo name and the nav-forward / nav-back slide belong in the /photos/[id] page file, which the paste does not hold.",
"transitionTypes on next/link needs Next.js 16.2 or newer; older versions need startTransition with addTransitionType and router.push.",
"Once the panel VT can exit, its fade-out sits on a page with a shared photo morph; swap the exit for slide-down so the source is not dissolved mid-morph."
]
}
What step 5 prints for it: V1: Import ViewTransition and startTransition from react by their stable names, as the page did. | V2: Run the sort update inside startTransition and drop the flushSync import, as the page did. | V3: Wrap each item in an outer ViewTransition keyed by photo.id, with no DOM node between the map and that VT, and keep the named VT inside it. | V4: Add share="morph" next to default="none" so only the shared pair animates, as the page did. | V5: Add the reduced-motion block to the global stylesheet, as the page did. | V6: Build the name from the photo id so each item is unique, as the page did. | V7: Add the fade-in recipe and the timing variables it uses to the global stylesheet, as the page did. | V8: Move the ViewTransition outside the panel div and render it conditionally on ordered.length so the VT itself appears and disappears.
The page's check: kept - X2 repeats what the page already found on line 24.
rewrite - A photo gallery with broken transitions
The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page re-scanned the rewritten code: 8 findings before, 0 after.
Input (facts is 2334 characters; the first 1397 are shown):
{
"task": "rewrite",
"unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router (React canary bundled - never install react@canary)",
"code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { unstable_ViewTransition as ViewTransition, startTransition } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport Link from \"next/link\";\n\ntype Photo = { id: string; src: string; title: string };\n\nexport function Gallery({ photos }: { photos: Photo[] }) {\n const [sort, setSort] = useState<\"new\" | \"old\">(\"new\");\n const ordered = sort === \"new\" ? photos : [...photos].reverse();\n\n function toggle() {\n flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\"));\n }\n\n return (\n <section>\n <button onClick={toggle}>Sort: {sort}</button>\n <ul className=\"grid\">\n {ordered.map((photo) => (\n <li key={photo.id}>\n <ViewTransition name=\"photo\" default=\"none\">\n <Link href={`/photos/${photo.id}`}>\n <img src={photo.src} alt={photo.title} />\n </Link>\n </ViewTransition>\n </li>\n ))}\n </ul>\n <div className=\"panel\">\n <ViewTransition enter=\"fade-in\" exit=\"fade-out\">\n <p>{ordered.length} photos</p>\n </ViewTransition>\n </div>\n </section>\n );\n}\n",
"css": ".grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }\n::view-transition-old(.fade-out) {\n animation: 150ms ease-in fade reverse;\n}\n@keyframes fade {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n",
"carry": "V1 | confirmed | the file | Import ViewTransition and startTransition from react by their stable names, as the page did.\nV2 | confirmed | Gallery | Run the sort update inside startTransition and drop the flushSync import, as the page did.\nV3 | confirmed | Gallery | Wrap each item in an outer ViewTransition keyed by photo.id, with no DOM node between the map and that VT, and keep the named VT inside it.\nV4 | confirmed | Gallery | Add share=\"morph\" next to default=\"none\" so only the shared pair animates, as the page did.\nV5 | confirmed | Gallery | Add the reduced-motion block to the global stylesheet, as the page did.\nV6 | confirmed | Gallery | Build the name from the photo id so each item is unique, as the page did.\nV7 | confirmed | Gallery | Add the fade-in recipe and the timing variables it uses to the global stylesheet, as the page did.\nV8 | confirmed | Gallery | Move the ViewTransition outside the panel div and render it conditionally on ordered.length so the VT itself appears and disappears.\nX1 | extra | Gallery (line 25) | Pass transitionTypes={[\"nav-forward\"]} on the Link so the detail page's directional VT resolves the forward slide.\nX2 | extra | Gallery (line 24) | On the /photos/[id] page, wrap the large image in ViewTransition name={\"photo-\" + id} share=\"morph\" with the same id.",
"facts": "The page's scan of 40 lines - 2 ViewTransitions, 1 named VT; 1 component (Gallery); 6 of 8 findings fixed by the page; verdict: Animates with gaps.\nClasses the VT props name: fade-in, fade-out; stylesheet pasted (9 lines); the page's CSS to add holds fade-in and the reduced-motion block.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | the file | unstable_ViewTransition is the canary name - React 19.3 and the Next.js App Router export ViewTransition. | source: import { unstable_ViewTransition as ViewTransition, startTransition } from \"react\"; | fixed\n- V2 | triggers | major | line 15 | Gallery | flushSync(...) commits synchronously, so the view transition is skipped. | source: flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\")); | fixed\n- V3 | names | major | line 24 | Gallery | A named VT inside a list item with no keyed <ViewTransition> around the item - the shared-element morph has no list-identity layer, so a reorder animates nothing. | source: <ViewTransition name=\"photo\" default=\"none\"> | left\n- V4 | props | blocker | line 24 | Gallery | A named VT with default=\"none\" and no share prop - share resolves to none, so the morph silently never fires. | source: <ViewTransition name=\"photo\" default=\"none\"> | fixed\n- V5 | a11y | minor | line 24 | Gallery | The stylesheet has no prefers-reduced-motion\n\n[... 937 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "rewrite",
"summary": "Gallery now imports the stable ViewTransition, sorts in startTransition, keys each photo around a per-id morph, mounts the panel VT on its own with a slide-down exit and tags the Link; the detail page name is left for the team.",
"code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { ViewTransition, startTransition } from \"react\";\nimport Link from \"next/link\";\n\ntype Photo = { id: string; src: string; title: string };\n\nexport function Gallery({ photos }: { photos: Photo[] }) {\n const [sort, setSort] = useState<\"new\" | \"old\">(\"new\");\n const [... 955 more characters cut from this display]",
"css": ":root {\n --duration-exit: 150ms;\n --duration-enter: 210ms;\n --duration-move: 400ms;\n}\n\n::view-transition-new(.fade-in) {\n animation: var(--duration-enter) ease-out var(--duration-exit) both fade;\n}\n\n::view-transition-old(.slide-down) {\n animation:\n var(--duration-exit) ease-out both fade reverse, [... 597 more characters cut from this display]",
"changes": [
{
"id": "V1",
"change": "The import line now pulls ViewTransition and startTransition from react by their stable names."
},
{
"id": "V2",
"change": "toggle in Gallery calls startTransition instead of flushSync, and the react-dom flushSync import is gone."
},
{
"id": "V3",
"change": "Each photo li in Gallery now sits inside an outer ViewTransition keyed by photo.id, directly under the map with no DOM node between."
},
{
"id": "V4",
"change": "The named photo VT in Gallery carries share=\"morph\" beside default=\"none\", and the CSS adds the morph recipe."
},
{
"id": "V5",
"change": "The CSS to add ends with the reduced-motion block for the global stylesheet."
},
{
"id": "V6",
"change": "The photo VT in Gallery is named \"photo-\" + photo.id so every item is unique."
},
{
"id": "V7",
"change": "The CSS adds the fade-in recipe and the timing variables it uses, reusing the pasted fade keyframes."
},
{
"id": "V8",
"change": "The panel ViewTransition in Gallery now wraps div.panel and renders only while ordered.length is above zero, exiting with slide-down instead of fade-out since the page has a shared morph."
},
{
"id": "X1",
"change": "The photo Link in Gallery passes transitionTypes={[\"nav-forward\"]}."
}
],
"kept": [],
"caveats": [
"X2 needs the /photos/[id] page, which the paste does not hold: wrap its large image in ViewTransition name={\"photo-\" + id} share=\"morph\" and give that page its nav-forward / nav-back slide.",
"transitionTypes on next/link needs Next.js 16.2 or newer; on older versions use startTransition with addTransitionType and router.push.",
"The pasted fade-out rule is no longer named by the file and can stay or go.",
"The photo href is now built with string concatenation; it points at the same path as before."
]
}
Cut from this display only (the saved reply and the check use the full text): code (1274 characters, 319 shown); css (902 characters, 305 shown).
What step 5 prints for it: V1: The import line now pulls ViewTransition and startTransition from react by their stable names. | V2: toggle in Gallery calls startTransition instead of flushSync, and the react-dom flushSync import is gone. | V3: Each photo li in Gallery now sits inside an outer ViewTransition keyed by photo.id, directly under the map with no DOM node between. | V4: The named photo VT in Gallery carries share="morph" beside default="none", and the CSS adds the morph recipe. | V5: The CSS to add ends with the reduced-motion block for the global stylesheet. | V6: The photo VT in Gallery is named "photo-" + photo.id so every item is unique. | V7: The CSS adds the fade-in recipe and the timing variables it uses, reusing the pasted fade keyframes. | V8: The panel ViewTransition in Gallery now wraps div.panel and renders only while ordered.length is above zero, exiting with slide-down instead of fade-out since the page has a shared morph. | X1: The photo Link in Gallery passes transitionTypes={["nav-forward"]}.
The page's check: kept - nothing to flag
review - A product page with no transitions yet
The page read it as: No transitions yet - 0 blocker, 0 major, 3 minor; findings V1 audit-nav, V2 audit-suspense, V3 audit-list.
Input:
{
"task": "review",
"unit": "File: app/products/[id]/page.tsx\nFramework: Next.js App Router (React canary bundled - never install react@canary)",
"code": "import { Suspense } from \"react\";\nimport Link from \"next/link\";\nimport { Reviews, ReviewsSkeleton } from \"./reviews\";\n\nexport default async function ProductPage({ product, related }) {\n return (\n <main>\n <img src={product.image} alt={product.name} className=\"hero\" />\n <h1>{product.name}</h1>\n <Suspense fallback={<ReviewsSkeleton />}>\n <Reviews productId={product.id} />\n </Suspense>\n <ul>\n {related.map((r) => (\n <li key={r.id}>\n <Link href={`/products/${r.id}`}>{r.name}</Link>\n </li>\n ))}\n </ul>\n </main>\n );\n}\n",
"css": "",
"carry": "",
"facts": "The page's scan of 23 lines - 0 ViewTransitions, 0 named VTs; 1 component (ProductPage); 0 of 3 findings fixed by the page; verdict: No transitions yet.\nClasses the VT props name: none; no stylesheet pasted; the page's CSS to add holds nothing.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | audit | minor | line 1 | the file | 1 navigation trigger (Link, router.push) and no ViewTransition - candidate route changes (pattern 5) and shared elements (pattern 1). | source: import { Suspense } from \"react\"; | candidate\n- V2 | audit | minor | line 10 | ProductPage | A <Suspense> boundary with no ViewTransition - a candidate Suspense reveal (pattern 2). | source: <Suspense fallback={<ReviewsSkeleton />}> | candidate\n- V3 | audit | minor | line 14 | ProductPage | A list rendered with .map() and no ViewTransition per item - a candidate list identity animation (pattern 3). | source: {related.map((r) => ( | candidate\nmust: account for every finding id above exactly once - in findings (confirmed) or in cleared (with the reason)."
}
Reply:
{
"task": "review",
"summary": "Nothing on ProductPage animates yet and the page fixed nothing; the first step is a keyed page-level ViewTransition so a related-product link cross-fades between products, then a Suspense reveal for the reviews.",
"patterns": [
{
"pattern": "shared element",
"where": "ProductPage hero image (img.hero)",
"why": "The product image arriving from a product list morphs into the hero, so the person sees the same item going deeper."
},
{
"pattern": "suspense reveal",
"where": "ProductPage Reviews Suspense boundary",
"why": "The skeleton slides away and the loaded reviews slide up, which says the data has arrived."
},
{
"pattern": "route change",
"where": "ProductPage main",
"why": "A related link swaps one product for a sibling on the same route, so a cross-fade marks a lateral move to a new place."
}
],
"nav_map": [
"/products/[id] -> /products/[id] | lateral | keyed page cross-fade (key + name + share=\"auto\")"
],
"findings": [
{
"id": "V1",
"widget": "ProductPage",
"issue": "The related Links navigate to another product with no ViewTransition anywhere, and the hero image is a natural shared element, yet neither animates.",
"fix": "Wrap main in a ViewTransition keyed by product.id with a stable name, share=\"auto\" and default=\"none\", and name the hero image per product with share=\"morph\".",
"code": "<ViewTransition key={product.id} name=\"product-page\" share=\"auto\" default=\"none\">\n <main>\n <ViewTransition name={\"product-\" + product.id} share=\"morph\">\n <img src={product.image} alt={product.name} className=\"hero\" />\n </ViewTransition>\n ...\n </main>\n</ViewTransition>"
},
{
"id": "V2",
"widget": "ProductPage",
"issue": "The Reviews Suspense boundary swaps the skeleton for the content with no ViewTransition, so the reveal is an instant jump.",
"fix": "Wrap the fallback in a VT with exit=\"slide-down\" and the content in a VT with enter=\"slide-up\" and default=\"none\", using string props only.",
"code": "<Suspense fallback={<ViewTransition exit=\"slide-down\"><ReviewsSkeleton /></ViewTransition>}>\n <ViewTransition enter=\"slide-up\" default=\"none\">\n <Reviews productId={product.id} />\n </ViewTransition>\n</Suspense>"
}
],
"cleared": [
{
"id": "V3",
"why": "ProductPage is an async server component with no state, so the related list only changes when product.id changes, and the keyed page VT already cross-fades that as one unit; per-item keys would never see a reorder."
}
],
"extra": [],
"plan": [
"Wrap main in a ViewTransition keyed by product.id with a stable name, share=\"auto\" and default=\"none\" (V1)",
"Name the hero image per product with share=\"morph\" to match the product list view (V1)",
"Wrap the Reviews fallback and content in separate exit=\"slide-down\" / enter=\"slide-up\" VTs (V2)",
"Add the slide-up / slide-down and morph recipes, the timing variables and the reduced-motion block to the global stylesheet (V1, V2)"
],
"caveats": [
"The hero morph needs the product list view, in another file, to mount ViewTransition name={\"product-\" + id} share=\"morph\" on its image.",
"Arriving here from that list is a forward step; the nav-forward / nav-back slide and the link transitionTypes belong to that navigation, outside this file.",
"No stylesheet was pasted, so all the CSS goes into the global stylesheet."
]
}
What step 5 prints for it: V1: Wrap main in a ViewTransition keyed by product.id with a stable name, share="auto" and default="none", and name the hero image per product with share="morph". | V2: Wrap the fallback in a VT with exit="slide-down" and the content in a VT with enter="slide-up" and default="none", using string props only. | V3: cleared
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./vtkit.js"); require("./vtreply.js")), then:
const K = VtKit, R = VtReply;
const f = K.form({ name: "app/card.tsx", framework: "next", css: "", carry: "",
code: "import { ViewTransition } from \"react\";\nexport function Card({ show }) {\n return show && <ViewTransition enter=\"scale-in\" exit=\"scale-out\"><p>Saved</p></ViewTransition>;\n}" });
// f.findings (each with .auto when the page fixed it), f.scan.migrated (the fixed copy), f.scan.css (the CSS to add), f.checklist
// K.findingsCsv(f), K.itemsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review"); // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b) // {status: kept or flags, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://view-transition-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://view-transition-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// view-transition-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://view-transition-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://view-transition-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ViewTransitionDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ViewTransitionDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://view-transition-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://view-transition-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://view-transition-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://view-transition-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"view-transition-desk"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "view-transition-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "view-transition-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "view-transition-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "view-transition-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "view-transition-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "view-transition-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "view-transition-desk" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "review", "unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router", "code": "\"use client\";\n...", "css": ".grid { ... }", "carry": "", "facts": "The page's scan of 40 lines - 2 ViewTransitions ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "review", "unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router", "code": "\"use client\";\n...", "css": ".grid { ... }", "carry": "", "facts": "The page's scan of 40 lines - 2 ViewTransitions ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "rewrite", unit, code, css, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses view-transition-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A review reply carries summary, patterns, nav_map, findings, cleared, extra, plan and caveats; a rewrite reply carries summary, code, css, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.
KEY="view-transition-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "rewrite" then (.changes[] | "\(.id): \(.change)")
else (.findings[] | "\(.id): \(.fix)") end' reply.json
import hashlib, time
key = "view-transition-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "rewrite":
for x in reply["changes"]:
print(x["id"], x["change"])
else:
for x in reply["findings"]:
print(x["id"], x["fix"])
import { createHash } from "node:crypto";
const key = `view-transition-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "rewrite") { for (const x of reply.changes) console.log(x.id, x.change); }
else { for (const x of reply.findings) console.log(x.id, x.fix); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "view-transition-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, b := "findings", "fix"
if reply["task"] == "rewrite" {
key, b = "changes", "change"
}
xs, _ := reply[key].([]any)
for _, x := range xs {
xx := x.(map[string]any)
fmt.Println(xx["id"], xx[b])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "view-transition-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("rewrite")) {
for (JsonNode x : reply.path("changes")) System.out.println(x.path("id").asText() + " " + x.path("change").asText());
} else {
for (JsonNode x : reply.path("findings")) System.out.println(x.path("id").asText() + " " + x.path("fix").asText());
}
require "digest"
key = "view-transition-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "rewrite"
reply["changes"].each { |x| puts "#{x["id"]} #{x["change"]}" }
else
reply["findings"].each { |x| puts "#{x["id"]} #{x["fix"]}" }
end
$key = "view-transition-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "rewrite") { foreach ($reply["changes"] as $x) echo $x["id"], " ", $x["change"], "\n"; }
else { foreach ($reply["findings"] as $x) echo $x["id"], " ", $x["fix"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"view-transition-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "rewrite") { foreach (var x in reply["changes"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["change"]}"); }
else { foreach (var x in reply["findings"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["fix"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: view-transition-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "review", "unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router", "code": "\"use client\";\n...", "css": ".grid { ... }", "carry": "", "facts": "The page's scan of 40 lines - 2 ViewTransitions ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "view-transition-desk:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `view-transition-desk:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "view-transition-desk:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "view-transition-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "view-transition-desk:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: view-transition-desk:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"view-transition-desk:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);