Author Lottie files from your own tools
Send a written brief and the canvas you need — width, height, frame rate, duration,
background — and get back one JSON object whose animation field is a
complete Lottie (Bodymovin) document: v, fr, ip,
op, w, h, nm, ddd,
assets and a non-empty layers array, sized exactly to the canvas
you asked for and authored against the constraints of lottie-web's light build: no
expressions, no layer effects, no image assets, text as shape paths or system fonts only.
Write that field to a .json file and any Lottie player — lottie-web,
lottie-react, the iOS and Android runtimes, After Effects via Bodymovin — will play it.
The same endpoints also take an existing animation back in for a plain-words refine. Every
code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a
language once and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api, app slug
lottie-studio. The slug is not a header — it is carried by the token: you
mint one for this app with POST /guest and a {"slug": "lottie-studio"}
body (or take the personal token this app already holds from the
token page), and every later call simply sends
Authorization: Bearer <token>. JSON bodies go up with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on
failure. Estimates are free; runs are metered against your credit balance. There are two run
tasks and no session state — generate makes a new animation from a brief,
refine takes an animation you already have plus feedback and edits it.
| Status | Meaning |
|---|---|
401 | Missing or expired token — mint a new one. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large refine payload). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1 — read it from your shell environment in real code
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
raise RuntimeError(payload.get("error", {}).get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — or read it from process.argv[2]
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered animation
runs billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your
clipboard, which every example below reads. Treat the token like a password: it can spend
your credits. For fully headless scripts, POST /guest mints a guest token with
no browser involved — and the slug in its body is what binds the token to
this app.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"lottie-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "lottie-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "lottie-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "lottie-studio"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"lottie-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "lottie-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "lottie-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "lottie-studio" });
var token = guest.GetProperty("token").GetString();
The app stores this browser's token under the localStorage key
skillsafe_app_token:lottie-studio, on the app's own origin. The
token page reads and manages it for you — you never need
to open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Worth checking before a long
run: an animation cut short by an exhausted balance still costs what it burned.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — Estimate the cost
Send exactly the input you would send to /run; the response's
hold_credits is the worst-case cost. Nothing is charged and no job is created,
so estimating is free — which matters here, because a long duration on a big canvas
means a lot of baked keyframes and a correspondingly large reply.
| Input field | Type | Notes |
|---|---|---|
task | string, required | "generate" to author a new animation from brief, or "refine" to edit one you already have. Refine additionally requires feedback and current_animation. |
brief | string, required | What to animate, in plain text. It may carry SVG path data, hex brand colors, the exact copy to render and the numbers a counter should hit — supplied assets are treated as ground truth, so geometry, colors and figures come back unchanged. The app clips the brief at 8000 characters, keeping the head and appending a marker line where it cut; do the same, or send something shorter. On a refine the brief stays as originally sent — the change goes in feedback. |
canvas | object, required | {w, h, fps, duration_frames}. w and h are pixels, 16–4096. fps is 12–120 (the web UI offers 24, 25, 30 and 60). duration_frames is 10–1800, or null to let the animation choose a length that fits the content. These are used exactly: the returned document's w, h and fr equal what you sent, and op - ip matches the duration you asked for. |
background | string | "auto" (decided by use case), "transparent", or a "#rrggbb" hex. A hex means a full-canvas background layer is baked into the bottom of the document rather than left to the player; "transparent" means no background layer at all. |
style | string | A preset name, or "" to let the brief decide: premium-settle, kinetic-snap, soft-interface, technical-trace, ambient-loop, playful-pop, data-confirm, phase-field. This sets pacing and easing character — it never adds chrome the brief did not ask for. |
feedback | string, refine only | What to change, in plain words — "hold the final frame half a second longer", "make the ring 4px thinner". The edit is applied surgically: timing, palette and layer names the feedback does not mention are preserved rather than regenerated. |
$model | string, optional | Per-run model override, the platform's reserved key: "gpt-sol" runs this one job on the frontier tier (best layout quality, higher hold) while the app's default stays gpt-terra. Any platform model id or tier alias is accepted; /estimate honors it too, so quote before you run. |
current_animation | object, refine only | The full Lottie JSON being refined — the same document you got back in animation, or a file you already have. Send the whole thing, not a summary. |
prescan_facts | array, optional | What a client-side linter mechanically found in current_animation: a list of {id, sev, label}. sev is block | warn | info; ids are the deterministic checks that fired — expressions, images, effects, kf-order, parent-cycle, parent-missing, font-files, hold-only, size, range, missing:op and friends. The web UI fills this from its own scan before a refine; API callers may omit the field entirely. |
cat > input.json <<'JSON'
{
"task": "generate",
"brief": "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on clockwise from 12 o'clock over the first third, then a white check strokes in inside it with a trim path, overshooting 4% and settling. Hold the finished mark for the last third. Nothing else on the canvas - no card, no border, no shadow.",
"canvas": { "w": 512, "h": 512, "fps": 60, "duration_frames": 90 },
"background": "transparent",
"style": "premium-settle"
}
JSON
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data.hold_credits'
BRIEF = (
"A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on "
"clockwise from 12 o'clock over the first third, then a white check strokes in "
"inside it with a trim path, overshooting 4% and settling. Hold the finished mark "
"for the last third. Nothing else on the canvas - no card, no border, no shadow."
)
payload = {
"task": "generate",
"brief": BRIEF,
"canvas": {"w": 512, "h": 512, "fps": 60, "duration_frames": 90},
"background": "transparent",
"style": "premium-settle",
}
est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const brief = [
"A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on",
"clockwise from 12 o'clock over the first third, then a white check strokes in",
"inside it with a trim path, overshooting 4% and settling. Hold the finished mark",
"for the last third. Nothing else on the canvas - no card, no border, no shadow.",
].join(" ");
const payload = {
task: "generate",
brief,
canvas: { w: 512, h: 512, fps: 60, duration_frames: 90 },
background: "transparent",
style: "premium-settle",
};
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const brief = "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) " +
"draws on clockwise from 12 o'clock over the first third, then a white check " +
"strokes in inside it with a trim path, overshooting 4% and settling. Hold the " +
"finished mark for the last third. Nothing else on the canvas - no card, no " +
"border, no shadow."
payload := map[string]any{
"task": "generate",
"brief": brief,
"canvas": map[string]any{
"w": 512, "h": 512, "fps": 60, "duration_frames": 90,
},
"background": "transparent",
"style": "premium-settle",
}
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String brief = """
A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on \
clockwise from 12 o'clock over the first third, then a white check strokes in \
inside it with a trim path, overshooting 4% and settling. Hold the finished mark \
for the last third. Nothing else on the canvas - no card, no border, no shadow.""";
String jsonPayload = """
{"task": "generate",
"brief": %s,
"canvas": {"w": 512, "h": 512, "fps": 60, "duration_frames": 90},
"background": "transparent",
"style": "premium-settle"}
""".formatted(toJsonString(brief));
String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
BRIEF = "A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws " \
"on clockwise from 12 o'clock over the first third, then a white check strokes " \
"in inside it with a trim path, overshooting 4% and settling. Hold the finished " \
"mark for the last third. Nothing else on the canvas - no card, no border, no shadow."
payload = { task: "generate",
brief: BRIEF,
canvas: { w: 512, h: 512, fps: 60, duration_frames: 90 },
background: "transparent",
style: "premium-settle" }
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$brief = <<<'BRIEF'
A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on
clockwise from 12 o'clock over the first third, then a white check strokes in
inside it with a trim path, overshooting 4% and settling. Hold the finished mark
for the last third. Nothing else on the canvas - no card, no border, no shadow.
BRIEF;
$payload = [
"task" => "generate",
"brief" => $brief,
"canvas" => ["w" => 512, "h" => 512, "fps" => 60, "duration_frames" => 90],
"background" => "transparent",
"style" => "premium-settle",
];
$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var brief = """
A success confirmation for a checkout flow. A 2px teal ring (#5eead4) draws on
clockwise from 12 o'clock over the first third, then a white check strokes in
inside it with a trim path, overshooting 4% and settling. Hold the finished mark
for the last third. Nothing else on the canvas - no card, no border, no shadow.
""";
var payload = new {
task = "generate",
brief,
canvas = new { w = 512, h = 512, fps = 60, duration_frames = 90 },
background = "transparent",
style = "premium-settle",
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");
A refine costs more than the same brief did to generate, because
current_animation goes up with the request and the whole edited document comes
back down. Estimate before you loop: three rounds of feedback on a 200 KB document is
not three cheap calls. Keep documents lean — under roughly 350 KB and 60 layers
— and both the price and the renderer stay comfortable.
Step 4 — Run it and wait for the animation
/run takes the same input as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds
until status is succeeded or failed. Animation runs
are long — every keyframe is written out by hand, so a 90-frame composition with a
trim-path draw-on is usually 60–180 s. Always send an Idempotency-Key
header so a network retry can't start a second, double-charged run. The reply is in
output — usually nested as output.output, and as a JSON
string, so parse defensively; step 6 has the parser. The samples below fetch the
job and hand the raw text to that parser.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: ls-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
[ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq -r '.data.error'; exit 1; }
# the reply object, still a JSON string one level down
echo "$JOB" | jq -r '.data.output.output' > reply.json
jq -r '"\(.title) — \(.summary)"' reply.json
import time
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": "ls-001"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
# raw is the reply JSON as text — parse_reply() in step 6 turns it into the object
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": crypto.randomUUID() });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output;
// raw is the reply JSON as text — parseReply() in step 6 turns it into the object
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output json.RawMessage `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
log.Fatal(job.Error)
}
// job.Output is {"output": "<json string>"} — unwrap once, then see step 6:
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
raw := wrapper.Output
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;
while (true) {
String job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// The reply is at data.output.output as a JSON string — parse it again to get
// {title, summary, background, loop, notes[], animation}. The animation object is
// a complete Lottie document: v, fr, ip, op, w, h, nm, ddd, assets[], layers[].
// Step 6 shows the fence-stripping parse and how to write animation.json.
started = api("POST", "/run", payload)
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
# raw is the reply JSON as text — parse_reply in step 6 turns it into the object
$started = api("POST", "/run", $payload);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
// $raw is the reply JSON as text — parse_reply() in step 6 turns it into the array
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1500);
}
var raw = job.GetProperty("output").GetProperty("output").GetString();
// raw is the reply JSON as text — ParseReply() in step 6 turns it into a JsonDocument
If the balance runs out mid-run the stream stops where it stopped, and what arrived is a
truncated JSON string rather than a complete document. The app recovers the partial text and
says so; over the API, treat a parse failure on an otherwise succeeded job as
exactly that — check charged_credits against your estimate, top up, and
run again with a fresh idempotency key.
The reply object — output schema
One JSON object, always the same shape. The interesting field is animation:
a complete Lottie document, not a fragment and not a description of one.
| Field | Type | Meaning |
|---|---|---|
title | string | A short human name for the animation, also written into the document's own nm. |
summary | string | One or two sentences: what it shows and what the motion idea is. |
background | string | "transparent" or a "#rrggbb" hex — what the document was authored against. A hex here means the bottom layer of animation.layers is a full-canvas rect with that fill; "transparent" means there is no background layer and your page or app shows through. |
loop | bool | Whether the composition is built to loop seamlessly. Pass it straight to your player's loop option — a false here means the last frame is the intended resting still. |
notes | string[] | Short usage notes — which layer holds the swappable copy, which slot carries the accent color, where to cut for a shorter version. Often empty. |
animation | object | The deliverable. A complete Lottie (Bodymovin) document. Fields below. |
Inside animation:
| Field | Meaning |
|---|---|
v | Bodymovin schema version, e.g. "5.7.4". |
fr | Frame rate — equals the canvas.fps you sent. |
ip / op | In point (inclusive) and out point (exclusive). ip: 0, op: 90, fr: 60 renders frames 0–89, one and a half seconds. op - ip matches the canvas.duration_frames you asked for, or the length chosen for you when you sent null. |
w / h | Canvas size in pixels — equals the canvas.w and canvas.h you sent. |
nm | Composition name, set to title. |
ddd | 0 — this is a 2D composition. |
assets | Usually []. Precomps may appear here; image assets never do. |
layers | Non-empty. Shape layers (ty: 4), precomps (ty: 0), solids (ty: 1), nulls (ty: 3) and, sparingly, system-font text (ty: 5). |
What the document is guaranteed not to contain, because lottie-web's light build cannot render it:
| Never present | Why, and what is used instead |
|---|---|
Expressions (x script fields) | The light build has no expression engine. Counters, orbits, staggered offsets and physics are baked into explicit keyframes, which is also why long compositions produce large replies. |
Layer effects (ef) | Stripped by the light build. Glow is faked with soft-edged duplicate shapes at low opacity; motion blur with offset duplicates. |
| Image assets, base64 payloads, external URLs, audio | Nothing is fetched at play time. Everything is vector shapes, so the file is self-contained and safe to serve from your own origin. |
| Font files | Text is either converted to shape paths, or uses a system family (Arial, Helvetica Neue, Georgia, Times New Roman, Courier New) with fOrigin: 0. Nothing is downloaded. |
A small, real reply for the brief above (the layer list is abbreviated — a real one is much longer):
{
"title": "Checkout success mark",
"summary": "A teal ring draws on clockwise, then a white check strokes in with a
4% overshoot and settles; the finished mark holds for the last third.",
"background": "transparent",
"loop": false,
"notes": [
"Layer \"ring\" carries the accent — change its stroke color to rebrand.",
"Cut op to 60 for a snappier version; the hold is the last 30 frames."
],
"animation": {
"v": "5.7.4",
"fr": 60,
"ip": 0,
"op": 90,
"w": 512,
"h": 512,
"nm": "Checkout success mark",
"ddd": 0,
"assets": [],
"layers": [
{
"ty": 4, "ind": 1, "nm": "check", "sr": 1, "ip": 0, "op": 90, "st": 0, "bm": 0,
"ks": { "o": { "a": 0, "k": 100 }, "p": { "a": 0, "k": [256, 256, 0] },
"a": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] },
"r": { "a": 0, "k": 0 } },
"shapes": [ /* path + stroke + trim (ty "tm") animating e from 0 to 104 to 100 */ ]
},
{
"ty": 4, "ind": 2, "nm": "ring", "sr": 1, "ip": 0, "op": 90, "st": 0, "bm": 0,
"ks": { "o": { "a": 0, "k": 100 }, "p": { "a": 0, "k": [256, 256, 0] },
"a": { "a": 0, "k": [0, 0, 0] }, "s": { "a": 0, "k": [100, 100, 100] },
"r": { "a": 0, "k": -90 } },
"shapes": [ /* ellipse + stroke + trim drawing 0 -> 100 over frames 0-30 */ ]
}
]
}
}
The canvas contract is worth asserting on in an automated pipeline: compare
animation.w, animation.h, animation.fr and
animation.op - animation.ip against the canvas you sent, and
background against what you asked for. That is exactly the delivery check the
app runs after every metered run, and it catches a drifted reply before it reaches a
renderer.
Step 5 — Stream the animation as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events, so you can show progress instead of a spinner — which matters here,
because a full Lottie document with baked keyframes takes a while to write out. This app's own
progress panel is this endpoint. Events are separated by a blank line; each has an
event: line and a data: line carrying JSON.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "title", "summary", "background" and "animation" keys as they arrive, then for "layers" once the document body starts. |
done | {job_id, status, charged_credits, output} | The final, authoritative result — read the reply from output.output rather than trusting concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: ls-$(date +%s)" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"title\":\"Checkout success mark\""}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":742,"output":{"output":"{...}"}}
import json, requests
result = None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": "ls-001"},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(data.get("message", "run failed"))
raw = result["output"]["output"] # authoritative
print("\ncharged:", result["charged_credits"], "credits")
# hand `raw` to parse_reply() in step 6
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !body) continue;
const data = JSON.parse(body);
if (name === "delta") process.stdout.write("."); // live progress
if (name === "done") done = data;
if (name === "error") throw new Error(data.message ?? "run failed");
}
}
const raw = done.output.output;
console.log(`\n${done.charged_credits} credits`);
// hand `raw` to parseReply() in step 6
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "ls-001")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // Lottie replies are large
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatal(data["message"])
}
}
}
raw := final["output"].(map[string]any)["output"].(string)
// hand `raw` to the parser in step 6
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "ls-001")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) System.out.print("."); // live progress
else if ("done".equals(event)) done = data;
else if ("error".equals(event)) throw new RuntimeException(data);
}
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// {title, summary, background, loop, notes[], animation}. Step 6 writes the
// animation object out as animation.json.
require "net/http"
require "json"
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "ls-001"
req.body = payload.to_json
event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print "." # live progress
when "done" then done = data
when "error" then raise (data["message"] || "run failed")
end
end
end
end
end
end
raw = done["output"]["output"]
puts "\n#{done["charged_credits"]} credits"
# hand `raw` to parse_reply in step 6
$event = null;
$done = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: ls-001",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { echo "."; } // live progress
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$raw = $done["output"]["output"];
echo "\n{$done['charged_credits']} credits\n";
// hand $raw to parse_reply() in step 6
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "ls-001");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta") Console.Write("."); // live progress
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var raw = final.RootElement.GetProperty("output").GetProperty("output").GetString();
Console.WriteLine($"\n{final.RootElement.GetProperty("charged_credits")} credits");
// hand `raw` to ParseReply() in step 6
In a browser, the native EventSource only speaks GET, and this endpoint is a
POST — read the fetch response body incrementally, as the JavaScript
sample above does. On an idempotent replay the server may answer with a plain JSON
envelope instead of an event stream; check the Content-Type before you start
parsing frames.
Step 6 — Parse the reply and save the .json
The model is asked for one JSON object and nothing else, but a stray code fence or preamble
is always possible. Strip a leading ```json fence, take the text between the
first { and the last }, and only then parse — that is what
the app does. Then lift .animation out of the envelope and write
that to disk: the envelope's title, summary,
loop and notes are metadata for you, while
animation alone is the file a Lottie player loads. Save it compact —
pretty-printing a keyframed document doubles its size for no benefit.
# reply.json came from step 4 (or from the done event in step 5).
# Strip a code fence if one slipped in, then keep only the object:
sed -e 's/^```json$//' -e 's/^```$//' reply.json \
| tr -d '\000' > reply.clean.json
# the deliverable is the .animation field, on its own, compact
jq -c '.animation' reply.clean.json > animation.json
# metadata for your pipeline
jq -r '"title: \(.title)",
"loop: \(.loop)",
"bg: \(.background)",
"canvas: \(.animation.w)x\(.animation.h) @ \(.animation.fr)fps, \(.animation.op - .animation.ip) frames",
"layers: \(.animation.layers | length)",
(.notes[]? | "note: \(.)")' reply.clean.json
# assert the canvas you asked for is the canvas you got
jq -e '.animation.w == 512 and .animation.h == 512 and .animation.fr == 60' \
reply.clean.json > /dev/null || { echo "canvas drifted"; exit 1; }
import json
def parse_reply(raw):
"""Turn the model's text into the reply object, fences and all."""
if isinstance(raw, dict):
return raw
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else ""
text = text.rsplit("```", 1)[0]
start, end = text.find("{"), text.rfind("}")
if start < 0 or end < start:
raise ValueError("no JSON object in the reply")
return json.loads(text[start:end + 1])
reply = parse_reply(raw)
anim = reply["animation"]
# the canvas contract — assert it before anything downstream trusts the file
canvas = payload["canvas"]
assert anim["w"] == canvas["w"] and anim["h"] == canvas["h"], "canvas size drifted"
assert anim["fr"] == canvas["fps"], "frame rate drifted"
assert anim["layers"], "animation.layers is empty"
print(reply["title"], "-", reply["summary"])
print(f'{anim["w"]}x{anim["h"]} @ {anim["fr"]}fps, '
f'{anim["op"] - anim["ip"]} frames, {len(anim["layers"])} layers, '
f'loop={reply["loop"]}, bg={reply["background"]}')
for n in reply.get("notes", []):
print(" note:", n)
# animation.json is the file any Lottie player loads
with open("animation.json", "w", encoding="utf-8") as fh:
json.dump(anim, fh, separators=(",", ":"))
with open("reply.json", "w", encoding="utf-8") as fh:
json.dump(reply, fh, indent=2)
import { writeFileSync } from "node:fs";
function parseReply(raw) {
if (typeof raw !== "string") return raw;
let text = raw.trim();
if (text.startsWith("```")) {
text = text.slice(text.indexOf("\n") + 1);
text = text.slice(0, text.lastIndexOf("```"));
}
const start = text.indexOf("{"), end = text.lastIndexOf("}");
if (start < 0 || end < start) throw new Error("no JSON object in the reply");
return JSON.parse(text.slice(start, end + 1));
}
const reply = parseReply(raw);
const anim = reply.animation;
const { canvas } = payload;
if (anim.w !== canvas.w || anim.h !== canvas.h) throw new Error("canvas size drifted");
if (anim.fr !== canvas.fps) throw new Error("frame rate drifted");
if (!anim.layers?.length) throw new Error("animation.layers is empty");
console.log(`${reply.title} - ${reply.summary}`);
console.log(`${anim.w}x${anim.h} @ ${anim.fr}fps, ${anim.op - anim.ip} frames, ` +
`${anim.layers.length} layers, loop=${reply.loop}, bg=${reply.background}`);
for (const n of reply.notes ?? []) console.log(" note:", n);
// animation.json is the file any Lottie player loads:
// lottie.loadAnimation({ container, renderer: "svg",
// loop: reply.loop, autoplay: true, path: "animation.json" })
writeFileSync("animation.json", JSON.stringify(anim));
writeFileSync("reply.json", JSON.stringify(reply, null, 2));
func parseReply(raw string) (map[string]any, error) {
text := strings.TrimSpace(raw)
if strings.HasPrefix(text, "```") {
if i := strings.Index(text, "\n"); i >= 0 {
text = text[i+1:]
}
if i := strings.LastIndex(text, "```"); i >= 0 {
text = text[:i]
}
}
start, end := strings.Index(text, "{"), strings.LastIndex(text, "}")
if start < 0 || end < start {
return nil, fmt.Errorf("no JSON object in the reply")
}
var reply map[string]any
err := json.Unmarshal([]byte(text[start:end+1]), &reply)
return reply, err
}
reply, err := parseReply(raw)
if err != nil {
log.Fatal(err)
}
anim := reply["animation"].(map[string]any)
if int(anim["w"].(float64)) != 512 || int(anim["fr"].(float64)) != 60 {
log.Fatal("canvas drifted from the request")
}
layers := anim["layers"].([]any)
fmt.Printf("%s - %s\n", reply["title"], reply["summary"])
fmt.Printf("%v x %v @ %v fps, %v frames, %d layers, loop=%v, bg=%v\n",
anim["w"], anim["h"], anim["fr"],
anim["op"].(float64)-anim["ip"].(float64), len(layers),
reply["loop"], reply["background"])
// animation.json is the file any Lottie player loads
out, _ := json.Marshal(anim)
os.WriteFile("animation.json", out, 0o644)
os.WriteFile("reply.json", []byte(raw), 0o644)
// Java 17+, with your JSON library of choice.
static String stripFence(String raw) {
String text = raw.strip();
if (text.startsWith("```")) {
text = text.substring(text.indexOf('\n') + 1);
text = text.substring(0, text.lastIndexOf("```"));
}
int start = text.indexOf('{'), end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new IllegalStateException("no JSON object in the reply");
return text.substring(start, end + 1);
}
// Parse stripFence(raw) into an object tree, then:
// reply.title, reply.summary, reply.background, reply.loop, reply.notes[]
// reply.animation — the complete Lottie document
// Assert the canvas before trusting it:
// animation.w == 512, animation.h == 512, animation.fr == 60,
// animation.op - animation.ip == 90, animation.layers is non-empty.
// Then write the animation node out compact — that file, and only that file,
// is what a Lottie player loads:
// Files.writeString(Path.of("animation.json"), mapper.writeValueAsString(animationNode));
// Files.writeString(Path.of("reply.json"), stripFence(raw));
require "json"
def parse_reply(raw)
return raw if raw.is_a?(Hash)
text = raw.strip
if text.start_with?("```")
text = text.split("\n", 2)[1].to_s
text = text.rpartition("```").first
end
start_i = text.index("{")
end_i = text.rindex("}")
raise "no JSON object in the reply" if start_i.nil? || end_i.nil?
JSON.parse(text[start_i..end_i])
end
reply = parse_reply(raw)
anim = reply["animation"]
canvas = payload[:canvas]
raise "canvas size drifted" unless anim["w"] == canvas[:w] && anim["h"] == canvas[:h]
raise "frame rate drifted" unless anim["fr"] == canvas[:fps]
raise "animation.layers is empty" if anim["layers"].to_a.empty?
puts "#{reply["title"]} - #{reply["summary"]}"
puts "#{anim["w"]}x#{anim["h"]} @ #{anim["fr"]}fps, #{anim["op"] - anim["ip"]} frames, " \
"#{anim["layers"].length} layers, loop=#{reply["loop"]}, bg=#{reply["background"]}"
(reply["notes"] || []).each { |n| puts " note: #{n}" }
# animation.json is the file any Lottie player loads
File.write("animation.json", JSON.generate(anim))
File.write("reply.json", JSON.pretty_generate(reply))
function parse_reply($raw): array {
if (is_array($raw)) { return $raw; }
$text = trim($raw);
if (str_starts_with($text, "```")) {
$nl = strpos($text, "\n");
$text = $nl === false ? "" : substr($text, $nl + 1);
$last = strrpos($text, "```");
if ($last !== false) { $text = substr($text, 0, $last); }
}
$start = strpos($text, "{");
$end = strrpos($text, "}");
if ($start === false || $end === false) {
throw new Exception("no JSON object in the reply");
}
return json_decode(substr($text, $start, $end - $start + 1), true);
}
$reply = parse_reply($raw);
$anim = $reply["animation"];
$canvas = $payload["canvas"];
if ($anim["w"] !== $canvas["w"] || $anim["h"] !== $canvas["h"]) {
throw new Exception("canvas size drifted");
}
if ($anim["fr"] !== $canvas["fps"]) { throw new Exception("frame rate drifted"); }
if (empty($anim["layers"])) { throw new Exception("animation.layers is empty"); }
echo "{$reply['title']} - {$reply['summary']}\n";
echo "{$anim['w']}x{$anim['h']} @ {$anim['fr']}fps, "
. ($anim["op"] - $anim["ip"]) . " frames, "
. count($anim["layers"]) . " layers, bg={$reply['background']}\n";
foreach ($reply["notes"] ?? [] as $n) { echo " note: $n\n"; }
// animation.json is the file any Lottie player loads
file_put_contents("animation.json", json_encode($anim));
file_put_contents("reply.json", json_encode($reply, JSON_PRETTY_PRINT));
static JsonDocument ParseReply(string raw)
{
var text = raw.Trim();
if (text.StartsWith("```"))
{
text = text[(text.IndexOf('\n') + 1)..];
text = text[..text.LastIndexOf("```")];
}
int start = text.IndexOf('{'), end = text.LastIndexOf('}');
if (start < 0 || end < start) throw new Exception("no JSON object in the reply");
return JsonDocument.Parse(text[start..(end + 1)]);
}
using var replyDoc = ParseReply(raw!);
var reply = replyDoc.RootElement;
var anim = reply.GetProperty("animation");
if (anim.GetProperty("w").GetInt32() != 512 || anim.GetProperty("fr").GetInt32() != 60)
throw new Exception("canvas drifted from the request");
var layers = anim.GetProperty("layers").GetArrayLength();
Console.WriteLine($"{reply.GetProperty("title")} - {reply.GetProperty("summary")}");
Console.WriteLine($"{anim.GetProperty("w")}x{anim.GetProperty("h")} @ " +
$"{anim.GetProperty("fr")}fps, " +
$"{anim.GetProperty("op").GetInt32() - anim.GetProperty("ip").GetInt32()} frames, " +
$"{layers} layers, loop={reply.GetProperty("loop")}, " +
$"bg={reply.GetProperty("background")}");
if (reply.TryGetProperty("notes", out var notes))
foreach (var n in notes.EnumerateArray()) Console.WriteLine($" note: {n}");
// animation.json is the file any Lottie player loads
await File.WriteAllTextAsync("animation.json", anim.GetRawText());
await File.WriteAllTextAsync("reply.json", reply.GetRawText());
animation.json is self-contained: no fonts, no images, no network calls at play
time, so you can serve it from your own origin and hand it to lottie-web, lottie-react,
lottie-ios, lottie-android or dotLottie unchanged. Pass reply.loop as the
player's loop option and paint reply.background behind the canvas yourself when
it is a hex — the document already carries that background layer, but matching the
surrounding page keeps the edges clean. To iterate, send the same document straight back as
current_animation with task: "refine" and a sentence of
feedback.