Put a Lottie readiness gate in your pipeline
The app's analysis runs in a browser, but the three judgement lanes are a plain HTTP API. Send the facts about a Lottie document and get back a structured verdict you can fail a build on: a per-runtime compatibility report, a costed weight-reduction plan, or production embed code. Every example below appears in eight languages — pick one and it stays picked.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api. Every request carries Authorization: Bearer <token> and nothing else — the token is bound to this app, so there is no slug header. The one exception is POST /guest, which takes no auth and carries {"slug": "lottie-ship"} in the body.
Every reply uses the same envelope: {"ok":true,"data":{…}} on success and {"ok":false,"error":{"code":"…","message":"…"}} on failure. Branch on ok, never on the HTTP status alone.
Error codes
| Code | Means | What to do |
|---|---|---|
UNAUTHORIZED | Missing or expired token | Mint a new one with POST /guest, or sign in for a personal token. |
INSUFFICIENT_CREDITS | Balance below the hold | Call /estimate first and compare hold_credits against /me. |
VALIDATION_ERROR | The input was not accepted | Check error.details; usually a missing task or a malformed probe. |
RATE_LIMITED | Too many requests | Back off and retry; never tight-loop. |
JOB_FAILED | The run terminated | Inspect error.message on the job; retry with the same Idempotency-Key. |
The task field comes first
This app has three lanes and one system prompt. task selects the lane and is required on every run. The rest of the input, and the shape of the reply, both depend on it.
| task | Answers | Extra input | Extra output |
|---|---|---|---|
"compat" | Will each runtime draw this file? | targets[], support_table[], derived_verdicts[], context | targets[] |
"slim" | How do I hit a KB budget? | budget_kb_gzipped, must_be_smooth_on, fidelity_tradeoff | savings[], projected_gzip_bytes |
"embed" | What is the integration code? | stack, playback, loading, context | code[] |
Two lanes over one document are two distinct runs. Put the lane in your Idempotency-Key or the second lane will return the first lane's cached answer.
Step 0 — A tiny client
Everything below uses this one helper. It sets the two required headers and unwraps the envelope.
export API="https://api.skillsafe.ai/v1/app-api"
export SLUG="lottie-ship"
# Every reply is {"ok":true,"data":{...}} or {"ok":false,"error":{...}}.
# jq -e '.ok' exits non-zero on the error envelope, so scripts can branch on it.
api() { # api <METHOD> <PATH> [BODY]
curl -sS -X "$1" "$API$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+--data "$3"}
}
import json, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
SLUG = "lottie-ship"
TOKEN = "YOUR_TOKEN" # from step 1
def api(method, path, body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + (token or TOKEN))
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read())
if not payload.get("ok"):
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]
const API = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "lottie-ship";
let TOKEN = "YOUR_TOKEN"; // from step 1
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const api = "https://api.skillsafe.ai/v1/app-api"
const slug = "lottie-ship"
var token = "YOUR_TOKEN" // from step 1
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, body any) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, api+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public final class LottieShip {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "lottie-ship";
static String token = "YOUR_TOKEN"; // from step 1
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
/** Returns the raw JSON body; use your JSON library of choice to read it. */
static String call(String method, String path, String jsonBody) throws Exception {
HttpRequest.BodyPublisher pub = jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody);
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method(method, pub)
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} or {"ok":false,"error":{...}}
}
}
require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
SLUG = "lottie-ship"
TOKEN = "YOUR_TOKEN" # from step 1
def api(method, path, body = nil, token: TOKEN)
uri = URI(API + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "lottie-ship";
$TOKEN = "YOUR_TOKEN"; // from step 1
function api(string $method, string $path, ?array $body = null, ?string $token = null): array {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . ($token ?? $TOKEN),
"Content-Type: application/json",
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public static class LottieShip
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "lottie-ship";
public static string Token = "YOUR_TOKEN"; // from step 1
static readonly HttpClient Http = new();
public static async Task<JsonElement> CallAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement;
if (!root.GetProperty("ok").GetBoolean())
{
var e = root.GetProperty("error");
throw new Exception($"{e.GetProperty("code").GetString()}: {e.GetProperty("message").GetString()}");
}
return root.GetProperty("data").Clone();
}
}
Step 1 — Get a token
A guest token needs no account and is enough for /estimate. For metered runs, use a personal token — sign in at /tokens.html and copy it, or copy the ready-made shell export from the same page.
# A guest token needs no account and is enough to call /estimate.
TOKEN=$(curl -sS -X POST "$API/guest" \
-H "Content-Type: application/json" -d "{\"slug\":\"$SLUG\"}" | jq -r '.data.token')
echo "${TOKEN:0:12}…"
guest = api("POST", "/guest", {"slug": SLUG}, token="") # /guest needs no auth
TOKEN = guest["token"]
print(TOKEN[:12] + "…")
const guest = await (await fetch(`${API}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG }),
})).json();
TOKEN = guest.data.token;
raw, err := call("POST", "/guest", map[string]string{"slug": slug})
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
}
json.Unmarshal(raw, &guest)
token = guest.Token
String body = call("POST", "/guest", "{\"slug\":\"" + SLUG + "\"}");
// body -> {"ok":true,"data":{"token":"aut_…","expires_at":…}}
// pull data.token with your JSON library and assign it to `token`.
guest = api("POST", "/guest", { "slug" => SLUG }, token: "")
TOKEN_VALUE = guest["token"]
$guest = api("POST", "/guest", ["slug" => SLUG], "");
$TOKEN = $guest["token"];
var guest = await LottieShip.CallAsync(HttpMethod.Post, "/guest", new { slug = "lottie-ship" });
LottieShip.Token = guest.GetProperty("token").GetString()!;
Step 2 — Check who you are and your balance
/me tells you whether the token is a guest or a person, and what the balance is. Compare it against hold_credits from step 3 before you run, so a 402 is impossible.
api GET /me | jq '.data | {subject_type, credits}'
# {"subject_type":"guest","credits":0}
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
raw, _ := call("GET", "/me", nil)
fmt.Println(string(raw))
System.out.println(call("GET", "/me", null));
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await LottieShip.CallAsync(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Step 3 — Build the input and estimate the cost
The Lottie document itself is never sent. You send probe: derived facts about the document. Everything the model needs is in there, and it is two orders of magnitude smaller than the file.
Only document, layers, counts, features_used and weight are load-bearing; findings, assets and the context string sharpen the answer considerably. Omitted fields are treated as "not measured", never as zero.
/estimate is free, runs no job and charges nothing. Its hold_credits is what gets reserved, not what you pay — the actual charge is usually far lower.
| Top-level key | When | What it means |
|---|---|---|
task | always | The lane. See the table above. |
probe | always | The derived facts, shown below. This is the model's ground truth and every reply is reconciled against it. |
context | optional | Free text from the user, capped at 2000 characters. compat and embed only. |
retry_note | on a reformat retry | Sent only when the previous reply did not parse as the envelope. The model is instructed to follow it. |
probe_trimmed | oversized probes | Sent when the payload exceeds 120 KB. The tail of findings, features_used and assets.images is dropped, largest entries kept, and this string names how many of each went. The model is instructed to declare the gap in notes[] and never to read an absent entry as an absent defect. Send it yourself if you trim the probe. |
A font entry in probe.assets.fonts may also carry origin — the exporter's font-origin
code (0 local, 1 CSS URL, 2 script URL, 3 font URL). It is often absent, and
absent means the exporter recorded nothing, not that the font is local.
cat > input.json <<'JSON'
{
"task": "compat",
"probe": {
"document": { "version": "5.9.6", "canvas": "512x512", "fps": 30,
"frames": 90, "duration_s": 3, "name": "Promo loop", "markers": 0 },
"layers": { "total": 53, "by_type": { "shape": 48, "text": 1, "image": 2, "precomp": 1, "solid": 1 },
"hidden": 3, "precomps": 1, "nesting_depth": 1 },
"counts": { "keyframes": 444, "vertices": 2256, "shapes": 194,
"masks": 0, "effects": 1, "expressions": 1, "staticProps": 310 },
"features_used": [
{ "key": "expressions", "label": "Expressions", "count": 1, "example": "Promo loop > Badge" },
{ "key": "mergepaths", "label": "Merge paths", "count": 1, "example": "Promo loop > Badge" },
{ "key": "blend", "label": "Blend modes", "count": 1, "example": "Promo loop > Badge (multiply)" },
{ "key": "text", "label": "Text layers", "count": 1, "example": "Promo loop > Headline" },
{ "key": "externalassets","label": "External image assets", "count": 1, "example": "images/product-shot.png" }
],
"assets": {
"images": [ { "id": "img_0", "embedded": false, "format": "png",
"bytes": 0, "size": "400x300", "path": "images/product-shot.png" } ],
"fonts": [ { "fName": "Inter", "fFamily": "Inter", "fStyle": "Regular", "ascent": null } ],
"baked_glyphs": 14
},
"findings": [
{ "id": "text-missing-animators", "severity": "block",
"label": "Text layer missing its animators array",
"occurrences": 1, "where": ["Promo loop > Headline"] },
{ "id": "font-missing-ascent", "severity": "block",
"label": "Font entry with no ascent", "occurrences": 1, "where": ["Inter"] }
],
"weight": { "bytes": 291117, "gzip_bytes": 106036, "gzip_measured": true,
"embedded_image_bytes": 21063, "baked_glyph_bytes": 54817,
"precision_saving_bytes": 57423, "long_floats": 20777,
"total_floats": 21082, "bytes_per_layer": 5493 }
},
"targets": [
{ "id": "web-svg", "label": "lottie-web (SVG)" },
{ "id": "skottie", "label": "Skottie / CanvasKit" }
],
"support_table": [
{ "feature": "Expressions", "used_times": 1,
"support": { "web-svg": "partial - only in the full build", "skottie": "no - no expression interpreter" } },
{ "feature": "Merge paths", "used_times": 1,
"support": { "web-svg": "no - not implemented", "skottie": "yes" } }
],
"derived_verdicts": [
{ "renderer": "lottie-web (SVG)", "level": "blocked", "dropped": ["Merge paths"] },
{ "renderer": "Skottie / CanvasKit", "level": "blocked", "dropped": ["Expressions"] }
],
"context": "hero animation on the marketing site"
}
JSON
api POST /estimate "$(cat input.json)" | jq '.data | {model, model_alias, hold_credits, min_credits}'
# {"model":"gpt-5.6-terra","model_alias":"gpt-terra","hold_credits":3480,"min_credits":420}
INPUT = {
"task": "compat",
"probe": probe, # the object shown above
"targets": [{"id": "web-svg", "label": "lottie-web (SVG)"},
{"id": "skottie", "label": "Skottie / CanvasKit"}],
"support_table": support_table,
"derived_verdicts": derived_verdicts,
"context": "hero animation on the marketing site",
}
est = api("POST", "/estimate", INPUT)
print(est["model_alias"], est["hold_credits"], "reserved")
const input = {
task: "compat",
probe, // the object shown above
targets: [
{ id: "web-svg", label: "lottie-web (SVG)" },
{ id: "skottie", label: "Skottie / CanvasKit" },
],
support_table: supportTable,
derived_verdicts: derivedVerdicts,
context: "hero animation on the marketing site",
};
const est = await api("POST", "/estimate", input);
console.log(est.model_alias, est.hold_credits);
input := map[string]any{
"task": "compat",
"probe": probe, // the object shown above
"targets": []map[string]string{
{"id": "web-svg", "label": "lottie-web (SVG)"},
{"id": "skottie", "label": "Skottie / CanvasKit"},
},
"context": "hero animation on the marketing site",
}
raw, err := call("POST", "/estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(raw))
String input = """
{ "task": "compat",
"probe": %PROBE%,
"targets": [ { "id": "web-svg", "label": "lottie-web (SVG)" } ],
"context": "hero animation on the marketing site" }
""".replace("%PROBE%", probeJson);
System.out.println(call("POST", "/estimate", input));
input = {
"task" => "compat",
"probe" => probe, # the object shown above
"targets" => [{ "id" => "web-svg", "label" => "lottie-web (SVG)" }],
"context" => "hero animation on the marketing site",
}
est = api("POST", "/estimate", input)
puts "#{est["model_alias"]} #{est["hold_credits"]}"
$input = [
"task" => "compat",
"probe" => $probe, // the array shown above
"targets" => [["id" => "web-svg", "label" => "lottie-web (SVG)"]],
"context" => "hero animation on the marketing site",
];
$est = api("POST", "/estimate", $input);
echo $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var input = new {
task = "compat",
probe, // the object shown above
targets = new[] { new { id = "web-svg", label = "lottie-web (SVG)" } },
context = "hero animation on the marketing site",
};
var est = await LottieShip.CallAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Step 4 — Run it and poll for the report
/run returns a job_id immediately. Poll /jobs/{id} until status is succeeded or failed; the reply text is at output.output.
Always send an Idempotency-Key, and include the lane in it. A network blip during a retry then costs nothing instead of billing the same work twice.
# Idempotency-Key must include the lane: two lanes over one document
# are two distinct runs and must not collide on one key.
KEY="lottie-ship:compat:$(shasum -a 256 input.json | cut -c1-32):a1"
JOB=$(curl -sS -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
--data @input.json | jq -r '.data.job_id')
until [ "$(api GET "/jobs/$JOB" | jq -r '.data.status')" = "succeeded" ]; do sleep 2; done
api GET "/jobs/$JOB" | jq -r '.data.output.output' > reply.json
import hashlib, time
key = "lottie-ship:compat:" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:32] + ":a1"
req = urllib.request.Request(API + "/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.loads(r.read())["data"]["job_id"]
while True:
job = api("GET", "/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
reply = json.loads(job["output"]["output"])
import { createHash } from "node:crypto";
const key = "lottie-ship:compat:" +
createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 32) + ":a1";
const res = await fetch(`${API}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(input),
});
const { data: { job_id } } = await res.json();
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
const reply = JSON.parse(job.output.output);
b, _ := json.Marshal(input)
sum := sha256.Sum256(b)
key := fmt.Sprintf("lottie-ship:compat:%x:a1", sum[:16])
req, _ := http.NewRequest("POST", api+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var started struct {
OK bool `json:"ok"`
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&started)
for {
raw, _ := call("GET", "/jobs/"+started.Data.JobID, nil)
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output)
break
}
time.Sleep(2 * time.Second)
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
String hex = HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
String key = "lottie-ship:compat:" + hex.substring(0, 32) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(API + "/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// read data.job_id, then poll GET /jobs/{id} until status is succeeded or failed.
require "digest"
key = "lottie-ship:compat:" + Digest::SHA256.hexdigest(JSON.dump(input))[0, 32] + ":a1"
uri = URI(API + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.dump(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = api("GET", "/jobs/#{job_id}")
break (reply = JSON.parse(job["output"]["output"])) if job["status"] == "succeeded"
raise "run failed" if job["status"] == "failed"
sleep 2
end
$key = "lottie-ship:compat:" . substr(hash("sha256", json_encode($input)), 0, 32) . ":a1";
$ch = curl_init(API . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
],
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
do {
sleep(2);
$job = api("GET", "/jobs/" . $jobId);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
$reply = json_decode($job["output"]["output"], true);
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var hex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..32].ToLowerInvariant();
var key = $"lottie-ship:compat:{hex}:a1";
var run = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Authorization = new AuthenticationHeaderValue("Bearer", LottieShip.Token);
run.Headers.Add("Idempotency-Key", key);
run.Content = new StringContent(json, Encoding.UTF8, "application/json");
var started = await new HttpClient().SendAsync(run);
// read data.job_id, then poll GET /jobs/{id} until status is succeeded or failed.
Step 5 — Or stream it
/run-stream is the same run over server-sent events. Events are {"type":"job"}, {"type":"delta","text":"…"} and {"type":"done"}. Concatenating every delta.text yields exactly the string /jobs/{id} would have returned.
Use this when you want progress. The app itself streams and advances its progress card on the section headings as they arrive.
# -N disables buffering so the deltas print as they arrive.
curl -sSN -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
--data @input.json
req = urllib.request.Request(API + "/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in [("Authorization", "Bearer " + TOKEN), ("Content-Type", "application/json"), ("Idempotency-Key", key),
("Accept", "text/event-stream")]:
req.add_header(h, v)
buf = ""
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("data: "):
evt = json.loads(line[6:])
if evt.get("type") == "delta":
buf += evt["text"]
elif evt.get("type") == "done":
break
reply = json.loads(buf)
const res = await fetch(`${API}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
"Accept": "text/event-stream",
},
body: JSON.stringify(input),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let pending = "", out = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += dec.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const evt = JSON.parse(line.slice(6));
if (evt.type === "delta") out += evt.text;
}
}
const reply = JSON.parse(out);
req, _ = http.NewRequest("POST", api+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
var out strings.Builder
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1024*1024), 8*1024*1024)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
var evt struct {
Type string `json:"type"`
Text string `json:"text"`
}
json.Unmarshal([]byte(line[6:]), &evt)
if evt.Type == "delta" {
out.WriteString(evt.Text)
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder out = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body()
.filter(l -> l.startsWith("data: "))
.forEach(l -> {
// parse l.substring(6); append the "text" field when "type" is "delta"
out.append(extractDeltaText(l.substring(6)));
});
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.dump(input)
out = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data: ")
evt = JSON.parse(line[6..].strip)
out << evt["text"] if evt["type"] == "delta"
end
end
end
end
reply = JSON.parse(out)
$out = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data: ")) {
$evt = json_decode(substr($line, 6), true);
if (($evt["type"] ?? "") === "delta") { $out .= $evt["text"]; }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$reply = json_decode($out, true);
var stream = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", LottieShip.Token);
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
stream.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var http = new HttpClient();
using var res = await http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data: ")) continue;
using var evt = JsonDocument.Parse(line[6..]);
if (evt.RootElement.GetProperty("type").GetString() == "delta")
sb.Append(evt.RootElement.GetProperty("text").GetString());
}
The reply — one envelope for all three lanes
The model returns a single JSON object. The outer shape never changes; only the lane-specific extras do. That is what lets one parser handle all three lanes.
| Field | Type | Notes |
|---|---|---|
task | string | Echoes the requested lane. |
title | string | Names the document and the lane. |
verdict | string | ship, ship-with-changes or do-not-ship. This is the field to gate a build on. |
summary | string | One paragraph: the finding that matters most. |
sections[] | array | {heading, body, items[]}; each item is {label, detail, sev, fix} with sev in block/warn/info. Always non-empty. |
checklist[] | string[] | Ordered, verifiable actions. Under 10 entries. |
facts_used[] | string[] | The probe facts the report rests on — use it to audit the answer. |
notes[] | string[] | Caveats and anything the probe could not measure. |
targets[] | array | compat only. {renderer, level, headline, actions[]}, one per requested runtime, level in clean/caveats/blocked. |
savings[] | array | slim only. {action, est_bytes, risk, detail}, ordered by value for risk. |
projected_gzip_bytes | number | slim only. Size after every step. |
code[] | array | embed only. {filename, lang, body}, complete runnable files. |
The app reconciles every reply against the probe before displaying it: a report more optimistic than the support table, a savings plan whose steps exceed the file size, or embed code that omits the asset path a file with external images needs are all flagged. If you are consuming this API directly, do the same — the probe facts you sent are the cheapest possible check.
Step 6 — Use it
Reading verdict and the block-severity items is enough to gate a deploy. The rest is for humans.
# reply.json is the envelope described below.
jq -r '.verdict, .summary' reply.json
jq -r '.targets[] | "\(.renderer): \(.level) — \(.headline)"' reply.json
jq -r '.checklist[] | "[ ] " + .' reply.json
print(reply["verdict"], "-", reply["title"])
for t in reply.get("targets", []):
print(f'{t["renderer"]}: {t["level"]} — {t["headline"]}')
for a in t["actions"]:
print(" -", a)
blockers = [i for s in reply["sections"] for i in s.get("items", []) if i["sev"] == "block"]
if blockers:
raise SystemExit(f"{len(blockers)} blocking defect(s) — not shippable")
console.log(reply.verdict, "-", reply.title);
for (const t of reply.targets ?? []) {
console.log(`${t.renderer}: ${t.level} — ${t.headline}`);
t.actions.forEach((a) => console.log(" -", a));
}
const blockers = reply.sections.flatMap((s) => s.items ?? []).filter((i) => i.sev === "block");
if (blockers.length) process.exitCode = 1; // fail the build
var reply struct {
Verdict string `json:"verdict"`
Title string `json:"title"`
Targets []struct {
Renderer string `json:"renderer"`
Level string `json:"level"`
Headline string `json:"headline"`
Actions []string `json:"actions"`
} `json:"targets"`
}
json.Unmarshal([]byte(out.String()), &reply)
fmt.Println(reply.Verdict, "-", reply.Title)
for _, t := range reply.Targets {
fmt.Printf("%s: %s - %s\n", t.Renderer, t.Level, t.Headline)
}
// Map the envelope onto a record and fail the build on a blocking verdict.
record Target(String renderer, String level, String headline, List<String> actions) {}
record Reply(String task, String title, String verdict, String summary,
List<Target> targets, List<String> checklist) {}
Reply reply = parse(out.toString(), Reply.class); // your JSON library
if ("do-not-ship".equals(reply.verdict())) {
throw new IllegalStateException("Lottie is not shippable: " + reply.summary());
}
puts "#{reply["verdict"]} - #{reply["title"]}"
(reply["targets"] || []).each do |t|
puts "#{t["renderer"]}: #{t["level"]} - #{t["headline"]}"
t["actions"].each { |a| puts " - #{a}" }
end
abort("not shippable") if reply["verdict"] == "do-not-ship"
echo $reply["verdict"], " - ", $reply["title"], PHP_EOL;
foreach ($reply["targets"] ?? [] as $t) {
echo "{$t["renderer"]}: {$t["level"]} - {$t["headline"]}", PHP_EOL;
foreach ($t["actions"] as $a) { echo " - $a", PHP_EOL; }
}
if ($reply["verdict"] === "do-not-ship") { exit(1); }
using var doc = JsonDocument.Parse(sb.ToString());
var reply = doc.RootElement;
Console.WriteLine($"{reply.GetProperty("verdict").GetString()} - {reply.GetProperty("title").GetString()}");
foreach (var t in reply.GetProperty("targets").EnumerateArray())
Console.WriteLine($"{t.GetProperty("renderer").GetString()}: {t.GetProperty("level").GetString()}");
if (reply.GetProperty("verdict").GetString() == "do-not-ship")
Environment.Exit(1);
Worked example per lane
The only differences between lanes are the extra input fields. Same client, same envelope, same parser.
# compat — will my targets draw it?
jq -n --argjson probe "$(cat probe.json)" '{
task: "compat",
probe: $probe,
targets: [ {id:"web-svg", label:"lottie-web (SVG)"},
{id:"ios", label:"lottie-ios"} ],
context: "same file has to run on the marketing site and in the iOS app"
}' > input.json
# slim — how do I hit 50 KB?
jq -n --argjson probe "$(cat probe.json)" '{
task: "slim",
probe: $probe,
budget_kb_gzipped: 50,
must_be_smooth_on: "mid-range-mobile",
fidelity_tradeoff: "imperceptible"
}' > input.json
# embed — what is the React code?
jq -n --argjson probe "$(cat probe.json)" '{
task: "embed",
probe: $probe,
stack: "react",
playback: "on-view",
loading: "lazy",
context: "strict CSP, no inline scripts"
}' > input.json
COMPAT = {"task": "compat", "probe": probe,
"targets": [{"id": "web-svg", "label": "lottie-web (SVG)"},
{"id": "ios", "label": "lottie-ios"}],
"context": "same file runs on the marketing site and in the iOS app"}
SLIM = {"task": "slim", "probe": probe,
"budget_kb_gzipped": 50,
"must_be_smooth_on": "mid-range-mobile", # or modern-desktop, low-end-mobile, smart-tv
"fidelity_tradeoff": "imperceptible"} # or lossless, aggressive
EMBED = {"task": "embed", "probe": probe,
"stack": "react", # next, vue, svelte, vanilla, webcomponent, ios, android
"playback": "on-view", # autoplay-loop, autoplay-once, on-hover, scroll-scrub
"loading": "lazy", # bundled, cdn
"context": "strict CSP, no inline scripts"}
for name, payload in (("compat", COMPAT), ("slim", SLIM), ("embed", EMBED)):
est = api("POST", "/estimate", payload)
print(name, est["hold_credits"], "credits reserved")
const compat = { task: "compat", probe,
targets: [{ id: "web-svg", label: "lottie-web (SVG)" }, { id: "ios", label: "lottie-ios" }],
context: "same file runs on the marketing site and in the iOS app" };
const slim = { task: "slim", probe,
budget_kb_gzipped: 50,
must_be_smooth_on: "mid-range-mobile", // modern-desktop | low-end-mobile | smart-tv
fidelity_tradeoff: "imperceptible" }; // lossless | aggressive
const embed = { task: "embed", probe,
stack: "react", // next | vue | svelte | vanilla | webcomponent | ios | android
playback: "on-view", // autoplay-loop | autoplay-once | on-hover | scroll-scrub
loading: "lazy", // bundled | cdn
context: "strict CSP, no inline scripts" };
for (const [name, payload] of Object.entries({ compat, slim, embed })) {
const est = await api("POST", "/estimate", payload);
console.log(name, est.hold_credits);
}
compat := map[string]any{
"task": "compat",
"probe": probe,
"targets": []map[string]string{
{"id": "web-svg", "label": "lottie-web (SVG)"},
{"id": "ios", "label": "lottie-ios"},
},
"context": "same file runs on the marketing site and in the iOS app",
}
slim := map[string]any{
"task": "slim",
"probe": probe,
"budget_kb_gzipped": 50,
"must_be_smooth_on": "mid-range-mobile", // modern-desktop | low-end-mobile | smart-tv
"fidelity_tradeoff": "imperceptible", // lossless | aggressive
}
embed := map[string]any{
"task": "embed",
"probe": probe,
"stack": "react", // next | vue | svelte | vanilla | webcomponent | ios | android
"playback": "on-view", // autoplay-loop | autoplay-once | on-hover | scroll-scrub
"loading": "lazy", // bundled | cdn
"context": "strict CSP, no inline scripts",
}
for name, payload := range map[string]any{"compat": compat, "slim": slim, "embed": embed} {
raw, err := call("POST", "/estimate", payload)
if err != nil {
panic(err)
}
fmt.Println(name, string(raw))
}
// The three lanes differ only in their extra fields; the client and the reply
// parser are shared, which is the whole point of the single envelope.
String compat = """
{ "task": "compat", "probe": %s,
"targets": [ { "id": "web-svg", "label": "lottie-web (SVG)" },
{ "id": "ios", "label": "lottie-ios" } ],
"context": "same file runs on the marketing site and in the iOS app" }
""".formatted(probeJson);
String slim = """
{ "task": "slim", "probe": %s,
"budget_kb_gzipped": 50,
"must_be_smooth_on": "mid-range-mobile",
"fidelity_tradeoff": "imperceptible" }
""".formatted(probeJson);
String embed = """
{ "task": "embed", "probe": %s,
"stack": "react", "playback": "on-view", "loading": "lazy",
"context": "strict CSP, no inline scripts" }
""".formatted(probeJson);
for (String payload : List.of(compat, slim, embed)) {
System.out.println(call("POST", "/estimate", payload));
}
compat = {
"task" => "compat",
"probe" => probe,
"targets" => [{ "id" => "web-svg", "label" => "lottie-web (SVG)" },
{ "id" => "ios", "label" => "lottie-ios" }],
"context" => "same file runs on the marketing site and in the iOS app",
}
slim = {
"task" => "slim",
"probe" => probe,
"budget_kb_gzipped" => 50,
"must_be_smooth_on" => "mid-range-mobile", # modern-desktop | low-end-mobile | smart-tv
"fidelity_tradeoff" => "imperceptible", # lossless | aggressive
}
embed = {
"task" => "embed",
"probe" => probe,
"stack" => "react", # next | vue | svelte | vanilla | webcomponent | ios | android
"playback" => "on-view", # autoplay-loop | autoplay-once | on-hover | scroll-scrub
"loading" => "lazy", # bundled | cdn
"context" => "strict CSP, no inline scripts",
}
{ "compat" => compat, "slim" => slim, "embed" => embed }.each do |name, payload|
est = api("POST", "/estimate", payload)
puts "#{name} #{est["hold_credits"]} credits reserved"
end
$compat = [
"task" => "compat",
"probe" => $probe,
"targets" => [["id" => "web-svg", "label" => "lottie-web (SVG)"],
["id" => "ios", "label" => "lottie-ios"]],
"context" => "same file runs on the marketing site and in the iOS app",
];
$slim = [
"task" => "slim",
"probe" => $probe,
"budget_kb_gzipped" => 50,
"must_be_smooth_on" => "mid-range-mobile", // modern-desktop | low-end-mobile | smart-tv
"fidelity_tradeoff" => "imperceptible", // lossless | aggressive
];
$embed = [
"task" => "embed",
"probe" => $probe,
"stack" => "react", // next | vue | svelte | vanilla | webcomponent | ios | android
"playback" => "on-view", // autoplay-loop | autoplay-once | on-hover | scroll-scrub
"loading" => "lazy", // bundled | cdn
"context" => "strict CSP, no inline scripts",
];
foreach (["compat" => $compat, "slim" => $slim, "embed" => $embed] as $name => $payload) {
$est = api("POST", "/estimate", $payload);
echo $name, " ", $est["hold_credits"], " credits reserved", PHP_EOL;
}
var compat = new {
task = "compat",
probe,
targets = new[] {
new { id = "web-svg", label = "lottie-web (SVG)" },
new { id = "ios", label = "lottie-ios" },
},
context = "same file runs on the marketing site and in the iOS app",
};
var slim = new {
task = "slim",
probe,
budget_kb_gzipped = 50,
must_be_smooth_on = "mid-range-mobile", // modern-desktop | low-end-mobile | smart-tv
fidelity_tradeoff = "imperceptible", // lossless | aggressive
};
var embed = new {
task = "embed",
probe,
stack = "react", // next | vue | svelte | vanilla | webcomponent | ios | android
playback = "on-view", // autoplay-loop | autoplay-once | on-hover | scroll-scrub
loading = "lazy", // bundled | cdn
context = "strict CSP, no inline scripts",
};
foreach (var (name, payload) in new (string, object)[] { ("compat", compat), ("slim", slim), ("embed", embed) })
{
var est = await LottieShip.CallAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{name} {est.GetProperty("hold_credits").GetInt32()}");
}