← Motion Word
API

Drive Motion Word from your own code

Two metered tasks are available over HTTP. name takes a loose description of a web animation or motion effect and returns its exact term from a 91-term glossary, 0-2 close alternates with how each differs, an approximation flag and a prompt phrase. build takes a glossary term and returns a build spec: the gate decision, the purpose, the tool, from/to values, the curve and duration or spring, interruption and exit, the reduced-motion variant, the code and a feel-check.

The division of labour first: the page owns the tables; the model makes the calls. For name the page runs a keyword word-match and sends its top terms as hints; it always shows definitions from the glossary itself. For build the page applies the rules from the animate skill - the frequency gate (a keyboard shortcut or 100+ times a day means no animation), the duration range for the element, the curve token, the allowed properties - and sends them as facts. Afterwards it checks the reply against the same tables (MotionKit.check).

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"motion-word"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task; for name description; for build term, description, element, frequency, trigger, facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both tasks are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field and the inputs

Every run sends task: name or build. A missing or unknown task is answered as the closest one, and the reply's task field names the one chosen. All fields are strings.

task: name

FieldRequiredWhat it holds
taskyesname.
descriptionyesWhat the motion looks or feels like, in plain words; at most 600 characters (the page drops the middle of a longer one and marks it [...]).
contextnoWhere it was seen, at most 200 characters, or none.
hintsyesThe page's word-match: numbered lines N. Term (score S: matched words), or a line starting none.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

task: build

FieldRequiredWhat it holds
taskyesbuild.
term and definitionyesA glossary term spelled as in the glossary, and its definition.
descriptionyesWhat should move, and when; at most 600 characters.
elementyesOne of button, tooltip, popover, modal, drawer, toast, accordion, list, tabs, hover, drag, marketing, loop, other.
frequencyyes100+, tens, occasional or rare - how many times a day someone sees it.
triggeryespointer, hover, keyboard, scroll, mount or gesture.
factsyesThe page's lines: the gate decision and why, the duration range and transform-origin for the element, the curve token, the allowed properties and the recipe.
retry_notenoOnly on a reformat retry.

The replies

One JSON object, no prose around it. For name:

{"task": "name", "best": {"term": "Pop in", "why": "..."}, "alternates": [{"term": "Bounce", "differs": "..."}], "approximate": false, "approx_note": "", "compound": "", "prompt_phrase": "..."}

For build:

{"task": "build", "term": "...", "decision": "animate | subtle | none", "tier": "...", "purpose": "...", "reason": "...", "tool": "css-transition | css-starting-style | css-animation | waapi | motion | none", "properties": ["opacity", "transform"], "from": {...}, "to": {...}, "transform_origin": "...", "easing": "ease-out-strong | ease-in-out-strong | ease-drawer | ease | linear | spring | none", "duration_ms": 200, "spring": null, "stagger_ms": null, "interrupt": "...", "exit": "...", "reduced_motion": "...", "code": "...", "feel_check": "...", "alternative": ""}
FieldWhat it holds
best, alternatesGlossary terms only, spelled as in the glossary; 0-2 alternates, each with one line on when it is the right word instead.
approximate, approx_note, compoundWhen no term fits exactly: true, how it falls short, and the effect described in the glossary's words.
prompt_phraseOne sentence to hand to a designer or an AI, using the term.
decisionAgrees with the gate in facts: none for keyboard-triggered or 100+-a-day motion (then alternative says what to do instead), subtle at tens a day.
easing, duration_ms, springA curve token and a duration inside the element's range, or spring with {duration, bounce}, bounce 0.1-0.3.
from, toCSS values of the animated element; the page previews opacity, transform, clip-path, filter (blur) and height in px.
codeThe implementation with the reduced-motion variant and any hover gating included.

The page then checks the reply (MotionKit.check) and lists what it finds under "The page's check": terms outside the glossary, a decision against the gate, a curve that is not a token, a duration out of range, a bounce above 0.3, scale(0), transition: all, ease-in, ungated :hover, a missing reduced-motion variant.

Worked examples - ten samples, both tasks

These are the free samples on the page: the exact input the page sends and the reply that replays for free. The five name samples include an approximation; the five build samples cover animate, subtle, a spring and a gate refusal.

task: name

The bouncy popover - Pop in

Input:

{
 "task": "name",
 "description": "the bouncy thing when a popover opens",
 "context": "none",
 "hints": "1. Pop in (score 5.5: bouncy, bouncy thing)"
}

Reply:

{
 "task": "name",
 "best": {
  "term": "Pop in",
  "why": "The popover appears with a slight overshoot that reads as bouncy, which is exactly Pop in."
 },
 "alternates": [
  {
   "term": "Bounce",
   "differs": "use this when you mean the spring's overshoot quality itself, not the whole appear-with-overshoot entrance."
  }
 ],
 "approximate": false,
 "approx_note": "",
 "compound": "",
 "prompt_phrase": "Make the popover pop in with a slight bounce as it opens."
}

Menu out of the button - Origin-aware animation

Input:

{
 "task": "name",
 "description": "the menu seems to grow out of the button I clicked instead of from its middle",
 "context": "a project dashboard, the Sort by button",
 "hints": "1. Origin-aware animation (score 16: grow out of the button, out of the button, instead of from its middle)\n2. Scale in (score 1.5: grow)\n3. Scale (score 1.5: grow)"
}

Reply:

{
 "task": "name",
 "best": {
  "term": "Origin-aware animation",
  "why": "The menu grows out of the Sort by button that opened it instead of from its own center, which is the definition of an origin-aware animation."
 },
 "alternates": [
  {
   "term": "Scale in",
   "differs": "use this if only the growing-bigger motion matters and which point it grows from is not the point."
  }
 ],
 "approximate": false,
 "approx_note": "",
 "compound": "",
 "prompt_phrase": "Give the Sort by menu an origin-aware animation that grows out of the button instead of from its center."
}

The stretching pill - Morph

Input:

{
 "task": "name",
 "description": "the black pill at the top of the phone stretches into a bigger rounded box when a timer starts, then shrinks back",
 "context": "iPhone lock screen",
 "hints": "1. Scale (score 4.5: bigger, shrink, shrinks)\n2. Squash & stretch (score 1.5: stretch)"
}

Reply:

{
 "task": "name",
 "best": {
  "term": "Morph",
  "why": "The pill reshapes into a different rounded form and back, matching the glossary's own Dynamic Island example of one shape smoothly turning into another."
 },
 "alternates": [
  {
   "term": "Continuity transition",
   "differs": "use this to stress that the change keeps the user oriented across states, rather than naming the shape-changing animation itself."
  }
 ],
 "approximate": false,
 "approx_note": "",
 "compound": "",
 "prompt_phrase": "Morph the status pill into the larger rounded timer shape and back when the timer starts."
}

Pull past the end - Rubber-banding

Input:

{
 "task": "name",
 "description": "when I pull the list past the end it gets harder to pull and then snaps back when I let go",
 "context": "an iOS app",
 "hints": "1. Rubber-banding (score 4: snaps back)"
}

Reply:

{
 "task": "name",
 "best": {
  "term": "Rubber-banding",
  "why": "Growing resistance while pulling past the end and a snap-back on release is exactly the iOS overscroll feel Rubber-banding names."
 },
 "alternates": [],
 "approximate": false,
 "approx_note": "",
 "compound": "",
 "prompt_phrase": "Give the list rubber-banding resistance at the end that snaps back when released."
}

Letters dropping in - closest: Stagger

Input:

{
 "task": "name",
 "description": "the letters of the headline drop in one at a time and each one lands with a little squish",
 "context": "a landing page hero",
 "hints": "1. Press / Tap feedback (score 3: squish, squishes)"
}

Reply:

{
 "task": "name",
 "best": {
  "term": "Stagger",
  "why": "The letters come in one after another with a small delay between each, which is the cascade Stagger names."
 },
 "alternates": [
  {
   "term": "Squash & stretch",
   "differs": "names the squish each letter does on landing, not the one-after-another timing across letters."
  }
 ],
 "approximate": true,
 "approx_note": "Stagger only covers the one-at-a-time cascade; it says nothing about each letter squashing as it lands, so no single term covers the whole effect.",
 "compound": "a stagger of letters, each landing with a squash & stretch",
 "prompt_phrase": "Stagger the headline letters in one at a time, with each one squashing slightly as it lands."
}

task: build

Sort menu - animate, ease-out-strong

Input:

{
 "task": "build",
 "term": "Origin-aware animation",
 "definition": "An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center which is the default in CSS.",
 "description": "the Sort by menu under the table header opens when you click the Sort by button",
 "element": "popover",
 "frequency": "occasional",
 "trigger": "pointer",
 "facts": "gate: Occasionally (modals, drawers, toasts), trigger: a click or tap -> animate (occasional - standard animation)\nelement: Dropdown, popover, menu or select - duration 150-250 ms; transform-origin: trigger\ncurve: ease-out-strong = cubic-bezier(0.23, 1, 0.32, 1) (entering or exiting; the default)\nproperties allowed: opacity, transform, clip-path\nrecipe: Dropdown, popover, menu, select"
}

Reply:

{
 "task": "build",
 "term": "Origin-aware animation",
 "decision": "animate",
 "tier": "occasional",
 "purpose": "spatial-consistency",
 "reason": "Click-triggered popover, seen occasionally - the gate allows standard animation; the point is to keep the menu visually tied to the button it came from, not to decorate.",
 "tool": "css-transition",
 "properties": [
  "opacity",
  "transform"
 ],
 "from": {
  "opacity": "0",
  "transform": "scale(0.95)"
 },
 "to": {
  "opacity": "1",
  "transform": "scale(1)"
 },
 "transform_origin": "trigger",
 "easing": "ease-out-strong",
 "duration_ms": 200,
 "spring": null,
 "stagger_ms": null,
 "interrupt": "Opacity and transform are plain transitions, not keyframes, so closing and reopening the menu mid-animation retargets from wherever it currently is instead of restarting from scratch.",
 "exit": "Removing the open attribute reverses the same transition back to scale(0.95) and opacity 0 over 200ms; pointer-events turn off immediately so the fading menu can't still be clicked.",
 "reduced_motion": "Under prefers-reduced-motion the scale is dropped and the duration shortened to 80ms so opening is a quick cross-fade instead of a scale and fade.",
 "code": ".menu {\n  position: absolute;\n  transform-origin: var(--menu-origin, top left);\n  opacity: 0;\n  transform: scale(0.95);\n  pointer-events: none;\n  transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1), transform 200ms cubic-bezier(0.23, 1, 0.32, 1);\n}\n\n.menu[data-open=\"true\"] {\n  opacity: 1;\n  transform: scale(1);\n  pointer-events: auto;\n}\n\n/* JS sets --menu-origin to the corner nearest the Sort by button (e.g. \"top left\" or \"top right\") each time the menu opens. */\n\n@media (prefers-reduced-motion: reduce) {\n  .menu {\n    transition-duration: 80ms;\n    transform: none;\n  }\n  .menu[data-open=\"true\"] {\n    transform: none;\n  }\n}",
 "feel_check": "Toggle the menu open and closed quickly a few times - the transition should retarget smoothly with no pop or jump - and confirm in DevTools that it visibly grows from the button's corner, not the menu's own center.",
 "alternative": ""
}

Saved toast - animate, ease

Input:

{
 "task": "build",
 "term": "Slide in",
 "definition": "Element enters by sliding in from off-screen (left, right, top, or bottom).",
 "description": "a 'Saved to your library' toast appears at the bottom after saving a recipe",
 "element": "toast",
 "frequency": "occasional",
 "trigger": "mount",
 "facts": "gate: Occasionally (modals, drawers, toasts), trigger: it appearing on its own (load, new data) -> animate (occasional - standard animation)\nelement: Toast or notification - duration 200-400 ms; transform-origin: bottom\ncurve: ease = ease (hover and colour changes)\nproperties allowed: opacity, transform, clip-path\nrecipe: Toast"
}

Reply:

{
 "task": "build",
 "term": "Slide in",
 "decision": "animate",
 "tier": "occasional",
 "purpose": "feedback",
 "reason": "Appears on its own after a save, seen occasionally - the gate allows standard animation; its job is to confirm the save happened.",
 "tool": "css-starting-style",
 "properties": [
  "opacity",
  "transform"
 ],
 "from": {
  "opacity": "0",
  "transform": "translateY(100%)"
 },
 "to": {
  "opacity": "1",
  "transform": "translateY(0)"
 },
 "transform_origin": "bottom",
 "easing": "ease",
 "duration_ms": 400,
 "spring": null,
 "stagger_ms": null,
 "interrupt": "The entrance is a transition off @starting-style, not a keyframe animation, so if the toast is dismissed while still entering it retargets smoothly from its current position instead of jumping.",
 "exit": "A leaving class sets the same off-screen, transparent values, reversing the transition over 400ms; the toast is removed from the DOM only once that transition ends.",
 "reduced_motion": "Under prefers-reduced-motion the slide is dropped and the duration shortened to 150ms, so the toast still fades in and out but no longer travels from off-screen.",
 "code": ".toast {\n  position: fixed;\n  left: 50%;\n  bottom: 16px;\n  translate: -50% 0;\n  opacity: 1;\n  transform: translateY(0);\n  transition: opacity 400ms ease, transform 400ms ease;\n}\n\n@starting-style {\n  .toast {\n    opacity: 0;\n    transform: translateY(100%);\n  }\n}\n\n/* JS adds .toast--leaving to dismiss, then removes the node on transitionend. */\n.toast.toast--leaving {\n  opacity: 0;\n  transform: translateY(100%);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .toast {\n    transition-duration: 150ms;\n    transform: none;\n  }\n  .toast.toast--leaving {\n    transform: none;\n  }\n}",
 "feel_check": "Trigger two saves in quick succession and dismiss a toast mid-entrance - confirm it retargets smoothly with no jump, and that the fade/slide balance doesn't feel like a delay before the confirmation reads as done.",
 "alternative": ""
}

Command palette - none

Input:

{
 "task": "build",
 "term": "Scale in",
 "definition": "Element grows from smaller to full size as it appears, often paired with a fade.",
 "description": "the command palette that opens with Cmd+K, used all day by power users",
 "element": "modal",
 "frequency": "100+",
 "trigger": "keyboard",
 "facts": "gate: 100+ times a day (shortcuts, a command palette), trigger: a keyboard shortcut or key press -> none (keyboard-initiated actions are a disqualifier, not a judgment call)\nelement: Modal or dialog - duration 200-500 ms; transform-origin: center\ncurve: ease-out-strong = cubic-bezier(0.23, 1, 0.32, 1) (entering or exiting; the default)\nproperties allowed: opacity, transform, clip-path\nrecipe: Modal"
}

Reply:

{
 "task": "build",
 "term": "Scale in",
 "decision": "none",
 "tier": "100+",
 "purpose": "none",
 "reason": "Opened with Cmd+K and seen 100+ times a day by power users - keyboard-initiated actions are a disqualifier, not a judgment call, so this never animates.",
 "tool": "none",
 "properties": [],
 "from": {},
 "to": {},
 "transform_origin": "center",
 "easing": "none",
 "duration_ms": null,
 "spring": null,
 "stagger_ms": null,
 "interrupt": "",
 "exit": "",
 "reduced_motion": "",
 "code": ".command-palette {\n  display: none;\n}\n\n.command-palette[data-open=\"true\"] {\n  display: flex;\n}",
 "feel_check": "Confirm there is no perceptible delay between the keypress and the palette showing - for a 100+/day shortcut, instant has to mean instant.",
 "alternative": "Show and hide the palette with an instant display toggle on keydown/keyup - no easing, no duration. Put the effort into focus handling instead: move focus into the search input the moment it opens, and return focus to whatever was focused before when it closes."
}

Swipe a card away - animate, spring

Input:

{
 "task": "build",
 "term": "Swipe to dismiss",
 "definition": "Dragging an element off-screen to close it, like a drawer or toast.",
 "description": "a notification card you can swipe right to dismiss on a phone",
 "element": "drag",
 "frequency": "occasional",
 "trigger": "gesture",
 "facts": "gate: Occasionally (modals, drawers, toasts), trigger: a drag, swipe or flick -> animate (occasional - standard animation)\nelement: Something dragged, swiped or flicked - spring, not a duration; transform-origin: center\ncurve: spring (drag with momentum, interruptible gestures, things that should feel alive)\nspring: Apple-style { duration 0.3-0.6 s, bounce 0.1-0.3 }; bounce only for drag-to-dismiss and playful interactions\nproperties allowed: opacity, transform, clip-path\nrecipe: Drag to dismiss"
}

Reply:

{
 "task": "build",
 "term": "Swipe to dismiss",
 "decision": "animate",
 "tier": "occasional",
 "purpose": "feedback",
 "reason": "A drag/flick gesture, seen occasionally - the gate allows standard animation; the spring gives direct feedback that the card is tracking the finger and leaving for good.",
 "tool": "motion",
 "properties": [
  "opacity",
  "transform"
 ],
 "from": {
  "opacity": "1",
  "transform": "translateX(0%)"
 },
 "to": {
  "opacity": "0",
  "transform": "translateX(110%)"
 },
 "transform_origin": "center",
 "easing": "spring",
 "duration_ms": null,
 "spring": {
  "duration": 0.5,
  "bounce": 0.2
 },
 "stagger_ms": null,
 "interrupt": "The spring carries the card's current velocity, so re-grabbing it mid-flight or flicking it again while it's still returning redirects smoothly instead of snapping or restarting.",
 "exit": "Dragged past roughly 120px of offset or released with velocity above about 0.11 px/ms, the card springs the rest of the way off-screen to the right while fading out; released short of that, the same spring carries it back to center.",
 "reduced_motion": "Under prefers-reduced-motion the spring is replaced by a 150ms ease-out fade and short slide, keeping the dismiss action but removing the bounce and the drag-follow feel.",
 "code": "import { motion, useAnimation, useReducedMotion } from \"motion/react\";\n\nfunction DismissibleCard({ onDismiss, children }) {\n  const controls = useAnimation();\n  const reduce = useReducedMotion();\n  const exitTransition = reduce\n    ? { duration: 0.15, ease: \"easeOut\" }\n    : { type: \"spring\", duration: 0.5, bounce: 0.2 };\n\n  function handleDragEnd(event, info) {\n    const flicked = Math.abs(info.velocity.x) > 110;\n    const draggedFar = Math.abs(info.offset.x) > 120;\n    if (info.offset.x > 0 && (flicked || draggedFar)) {\n      controls.start({ x: \"110%\", opacity: 0, transition: exitTransition }).then(onDismiss);\n    } else {\n      controls.start({ x: 0, transition: exitTransition });\n    }\n  }\n\n  return (\n    <motion.div\n      className=\"notification-card\"\n      drag={reduce ? false : \"x\"}\n      dragDirectionLock\n      dragElastic={0.15}\n      dragConstraints={{ left: 0, right: 0 }}\n      onDragEnd={handleDragEnd}\n      animate={controls}\n      initial={{ x: 0, opacity: 1 }}\n      exit={{ x: \"110%\", opacity: 0, transition: exitTransition }}\n    >\n      {children}\n    </motion.div>\n  );\n}",
 "feel_check": "A spring's bounce can't be judged from the numbers - drag and release at different speeds and distances on a real touch device, and step through the settle in the DevTools animation inspector at 2-5x slower to confirm it feels snappy, not sluggish or over-bouncy.",
 "alternative": ""
}

Hover on list rows - subtle, ease

Input:

{
 "task": "build",
 "term": "Hover effect",
 "definition": "Visual change when the cursor moves over an element.",
 "description": "rows in a long file list highlight when the pointer moves over them",
 "element": "hover",
 "frequency": "tens",
 "trigger": "hover",
 "facts": "gate: Tens of times a day (hover, list navigation), trigger: the pointer hovering -> subtle (seen tens of times a day - near-imperceptible only, fast and subtle, or nothing)\nelement: A hover state - duration 100-200 ms; transform-origin: center\ncurve: ease = ease (hover and colour changes)\nproperties allowed: opacity, transform, clip-path, color, background-color, border-color"
}

Reply:

{
 "task": "build",
 "term": "Hover effect",
 "decision": "subtle",
 "tier": "tens",
 "purpose": "feedback",
 "reason": "Hover on rows in a long list, seen tens of times a day - the gate allows only a near-imperceptible change; a colour shift with no movement is the cheapest, subtlest way to show a row is interactive.",
 "tool": "css-transition",
 "properties": [
  "background-color"
 ],
 "from": {
  "background-color": "transparent"
 },
 "to": {
  "background-color": "rgba(0, 0, 0, 0.04)"
 },
 "transform_origin": "center",
 "easing": "ease",
 "duration_ms": 120,
 "spring": null,
 "stagger_ms": null,
 "interrupt": "A plain colour transition retargets instantly as the pointer moves row to row, so fast hovering never looks stuck mid-fade or restarts from zero.",
 "exit": "Moving the pointer off the row reverses the same 120ms transition back to transparent - same property, same curve, just running backward.",
 "reduced_motion": "There is no movement to drop here, only colour - under prefers-reduced-motion the transition is kept but shortened from 120ms to 80ms, fewer milliseconds without going to zero.",
 "code": ".file-row {\n  background-color: transparent;\n  transition: background-color 120ms ease;\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .file-row:hover {\n    background-color: rgba(0, 0, 0, 0.04);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .file-row {\n    transition-duration: 80ms;\n  }\n}",
 "feel_check": "This is meant to be barely-there - run a cursor quickly across several rows and confirm the highlight tracks without lag or flicker, and that it reads as present but not distracting.",
 "alternative": ""
}

Build the input yourself

Load the engine in node (global.window = global; require("./glossary.js"); require("./motionkit.js")), then:

const K = MotionKit;
const named = K.buildInput({ lane: "name", description: "the bouncy thing when a popover opens" });
// named.matches - the page's word-match; named.input - POST this to /estimate and /run
const spec = K.buildInput(K.handoff({ lane: "name", description: "the bouncy thing when a popover opens" }, "pop-in"));
// spec.input.facts - the gate, the range, the curve token and the allowed properties
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, spec).notes   // the page's re-check of the reply
// K.css(spec, reply)           // the CSS download

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. 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="paste-your-token-here"   # from https://motion-word.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"}
}

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":"motion-word"}' | jq -r '.data.token')

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}' 

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": "name", "description": "the bouncy thing when a popover opens", "context": "none", "hints": "...", ...}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

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 motion-word:<task>:<inputHash>:a<attempt> (the task is name or build), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="motion-word:name:$(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}' 

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: motion-word:name:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json