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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task; for name description; for build term, description, element, frequency, trigger, facts) is missing. |
| 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 tasks 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 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
| Field | Required | What it holds |
|---|---|---|
task | yes | name. |
description | yes | What 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 [...]). |
context | no | Where it was seen, at most 200 characters, or none. |
hints | yes | The page's word-match: numbered lines N. Term (score S: matched words), or a line starting none. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
task: build
| Field | Required | What it holds |
|---|---|---|
task | yes | build. |
term and definition | yes | A glossary term spelled as in the glossary, and its definition. |
description | yes | What should move, and when; at most 600 characters. |
element | yes | One of button, tooltip, popover, modal, drawer, toast, accordion, list, tabs, hover, drag, marketing, loop, other. |
frequency | yes | 100+, tens, occasional or rare - how many times a day someone sees it. |
trigger | yes | pointer, hover, keyboard, scroll, mount or gesture. |
facts | yes | The 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_note | no | Only 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": ""}
| Field | What it holds |
|---|---|
best, alternates | Glossary 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, compound | When no term fits exactly: true, how it falls short, and the effect described in the glossary's words. |
prompt_phrase | One sentence to hand to a designer or an AI, using the term. |
decision | Agrees 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, spring | A curve token and a duration inside the element's range, or spring with {duration, bounce}, bounce 0.1-0.3. |
from, to | CSS values of the animated element; the page previews opacity, transform, clip-path, filter (blur) and height in px. |
code | The 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://motion-word.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"]
// motion-word.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://motion-word.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 = "paste-your-token-here" // from https://motion-word.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
}
// MotionWord.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 MotionWord {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://motion-word.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 = "paste-your-token-here" # from https://motion-word.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 = "paste-your-token-here"; // from https://motion-word.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 = "paste-your-token-here"; // from https://motion-word.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":"motion-word"}' | 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": "motion-word"})["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: "motion-word" })).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": "motion-word"}, 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", "motion-word"), 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: "motion-word" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "motion-word"])["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 = "motion-word" }))!["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": "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}'
inp = json.load(open("input.json")) # {"task": "name", "description": "the bouncy thing when a popover opens", "context": "none", "hints": "...", ...}
# - see the field 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: "name", description, context, hints} (see the field 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 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}'
import hashlib, time
key = "motion-word:" + 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.get("best", {}).get("term"))
import { createHash } from "node:crypto";
const key = `motion-word:${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.best.term);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "motion-word:" + 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 }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "motion-word:" + 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);
require "digest"
key = "motion-word:#{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["best"]["term"]].inspect
$key = "motion-word:" . $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["best"]["term"] ?? ""), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"motion-word:{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>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "name", "description": "the bouncy thing when a popover opens", "context": "none", "hints": "...", ...}
# - see the field 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": "motion-word:name:" + 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": `motion-word:name:${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", "motion-word:name:"+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", "motion-word:name:" + 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" => "motion-word:name:#{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: motion-word:name:" . 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", $"motion-word:name:{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);