img2threejs — API

Measured image facts in, a procedural Three.js sculpt spec out.

API tokens Open the app

Reconstruct objects as code, from your own pipeline

img2threejs rebuilds the object in a reference image as a procedural 3D model: a hierarchy of Three.js primitives, lathe profiles, extruded outlines and tube paths with PBR materials, idle animations hung off group pivots, a suggested camera, and an honesty report naming everything a single view could not show. The model never sees your pixels. Your client measures the image — dominant palette, normalized silhouette contour, a 3×3 region colour grid, mirror symmetry, fill ratio — and sends those facts as text. What comes back is one strict JSON object you can render, diff, version and check. Everything the web app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it to an asset pipeline, a batch of product photos, or a build step that emits scene files. 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 img2threejs. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message", "details"}} on failure. Estimates are free; runs are metered against your credit balance. There is a single run task — one set of image facts in, one sculpt spec out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest starting a metered run).
404Unknown job or record id.
5xxTransient 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.

You compute the prescan. No image bytes are ever uploaded to the API. The prescan object described in step 3 is the model's only evidence about what the picture looks like, so the quality of your measurement is the quality of the reconstruction. If you send an empty or wrong prescan you will get a generic object built from the subject string alone, with a low meta.confidence to say so.

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 — read it from your shell environment in real code

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

POST /guest

A guest token lets you check balances and estimate costs for free. For metered reconstruction 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.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"img2threejs"}' | jq -r '.data.token'
guest = api("POST", "/guest", {"slug": "img2threejs"})
token, guest_id = guest["token"], guest["guest_id"]
const { token, guest_id } = await api("POST", "/guest", { slug: "img2threejs" });
var guest struct {
	Token   string `json:"token"`
	GuestID string `json:"guest_id"`
}
err := call("POST", "/guest", map[string]string{"slug": "img2threejs"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"img2threejs"}""");
// token is at data.token, and the guest id at data.guest_id
guest = api("POST", "/guest", { slug: "img2threejs" })
token = guest["token"]
$guest = api("POST", "/guest", ["slug" => "img2threejs"]);
$token = $guest["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "img2threejs" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:img2threejs, 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

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before firing a batch of reconstructions.

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 — Build the body and estimate the cost

POST /estimate

The request body has exactly two keys: fields, the measured facts, and instruction, the fixed sentence that tells the model what to do with them. Send the same body to /estimate first; the response's hold_credits is the worst-case cost, alongside min_credits, model, model_alias and markup_bps. Nothing is charged and no job is created, so estimating is free.

fields

FieldTypeNotes
subjectstring, requiredA short object name, at least 3 and at most 120 characters — espresso machine, BMX bike, toy rocket. This is treated as ground truth for identity: the model does not second-guess what the thing is, it reconstructs what you named.
descriptionstring, optionalFree-text notes, at most 4000 characters. Materials, distinctive features, and above all what the photo cannot show — "the handle is brass", "there are four legs", "the back is flat". Every sentence here reduces something that would otherwise land in report.hidden_assumptions.
detail_levelstringblockout | standard | high. A hard part budget of 14, 32 and 60 parts respectively. Blockout carries the silhouette only; high aims at full detail-inventory coverage. Higher levels cost more and take longer.
prescanobjectWhat your client measured from the image. The model never sees pixels, so this is its whole view of the picture. Columns below.

fields.prescan

KeyTypeMeaning
width, heightintPixel dimensions of the image you measured.
aspectfloatwidth / height. The reconstruction matches this: wider-than-tall subjects extend in x/z rather than y.
background_removedbooltrue when you stripped a detectable background before measuring, so palette entries are object colours only. Send false and the model will treat the largest flat colour with suspicion.
palettearray, ≤6[{"hex": "#rrggbb", "pct": 34.2}, …], sorted by coverage, most-covering first. Lowercase six-digit hex. Every colour the model uses must come from (or mix visibly close to) this list, and each one it uses is cited back in palette_used.
silhouettearray, ≤48The object's outline as normalized [x, y] pairs in image coordinates: x rightward 0..1, y downward 0..1, traced clockwise. This drives proportions and profile shape — it is what a lathe profile is fitted against (with y flipped, since model y is up). Fewer than about 8 points and the shape carries no information; more than 48 are rejected.
grid3×3 arrayRow-major from top-left: the dominant "#rrggbb" of each ninth of the image, or null where that region is empty or background. Used to put the right colour on the right part — a dark top region means a dark upper part.
symmetryobject{"vertical": 0..1}, a left-right mirror score. Above about 0.75 the model reaches for lathe bodies and mirrored part pairs instead of ad-hoc boxes.
fill_ratiofloat 0..1Fraction of the frame the object occupies. Low values warn that the subject is small and the silhouette coarse.

instruction

A fixed string — send it verbatim. It does not vary per request and it is not a place to add your own directions; the subject and its quirks belong in fields.description, which is the input the model is told to trust.

Reconstruct the object described in fields as a procedural Three.js sculpt spec.
Follow your system instructions exactly. Reply with ONLY the strict JSON spec
object - the first character of the reply is { and the last is }.

The worked example below is deliberately the simplest thing that still exercises every field: a plain red ball photographed on white, prescanned to an eight-point silhouette. Swap in your own measurements and the shape of the call does not change.

cat > input.json <<'JSON'
{
  "fields": {
    "subject": "red rubber ball",
    "description": "A single matte red rubber ball on a white studio background. Uniform colour all round; no seams, no logo.",
    "detail_level": "blockout",
    "prescan": {
      "width": 900, "height": 900, "aspect": 1.0,
      "background_removed": true,
      "palette": [
        {"hex": "#d0342c", "pct": 61.4},
        {"hex": "#8e211c", "pct": 12.7},
        {"hex": "#f2c8c4", "pct": 3.2}
      ],
      "silhouette": [
        [0.50, 0.10], [0.78, 0.22], [0.90, 0.50], [0.78, 0.78],
        [0.50, 0.90], [0.22, 0.78], [0.10, 0.50], [0.22, 0.22]
      ],
      "grid": [
        [null, "#d0342c", null],
        ["#d0342c", "#d0342c", "#d0342c"],
        [null, "#8e211c", null]
      ],
      "symmetry": {"vertical": 0.98},
      "fill_ratio": 0.63
    }
  },
  "instruction": "Reconstruct the object described in fields as a procedural Three.js sculpt spec. Follow your system instructions exactly. Reply with ONLY the strict JSON spec object - the first character of the reply is { and the last is }."
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps}'
INSTRUCTION = (
    "Reconstruct the object described in fields as a procedural Three.js sculpt spec. "
    "Follow your system instructions exactly. Reply with ONLY the strict JSON spec object "
    "- the first character of the reply is { and the last is }."
)

payload = {
    "fields": {
        "subject": "red rubber ball",
        "description": ("A single matte red rubber ball on a white studio background. "
                        "Uniform colour all round; no seams, no logo."),
        "detail_level": "blockout",
        "prescan": {
            "width": 900, "height": 900, "aspect": 1.0,
            "background_removed": True,
            "palette": [
                {"hex": "#d0342c", "pct": 61.4},
                {"hex": "#8e211c", "pct": 12.7},
                {"hex": "#f2c8c4", "pct": 3.2},
            ],
            "silhouette": [
                [0.50, 0.10], [0.78, 0.22], [0.90, 0.50], [0.78, 0.78],
                [0.50, 0.90], [0.22, 0.78], [0.10, 0.50], [0.22, 0.22],
            ],
            "grid": [
                [None, "#d0342c", None],
                ["#d0342c", "#d0342c", "#d0342c"],
                [None, "#8e211c", None],
            ],
            "symmetry": {"vertical": 0.98},
            "fill_ratio": 0.63,
        },
    },
    "instruction": INSTRUCTION,
}

est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits on", est["model_alias"])
const INSTRUCTION =
  "Reconstruct the object described in fields as a procedural Three.js sculpt spec. " +
  "Follow your system instructions exactly. Reply with ONLY the strict JSON spec object " +
  "- the first character of the reply is { and the last is }.";

const payload = {
  fields: {
    subject: "red rubber ball",
    description:
      "A single matte red rubber ball on a white studio background. Uniform colour all round; no seams, no logo.",
    detail_level: "blockout",
    prescan: {
      width: 900, height: 900, aspect: 1.0,
      background_removed: true,
      palette: [
        { hex: "#d0342c", pct: 61.4 },
        { hex: "#8e211c", pct: 12.7 },
        { hex: "#f2c8c4", pct: 3.2 },
      ],
      silhouette: [
        [0.50, 0.10], [0.78, 0.22], [0.90, 0.50], [0.78, 0.78],
        [0.50, 0.90], [0.22, 0.78], [0.10, 0.50], [0.22, 0.22],
      ],
      grid: [
        [null, "#d0342c", null],
        ["#d0342c", "#d0342c", "#d0342c"],
        [null, "#8e211c", null],
      ],
      symmetry: { vertical: 0.98 },
      fill_ratio: 0.63,
    },
  },
  instruction: INSTRUCTION,
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model_alias);
const instruction = "Reconstruct the object described in fields as a procedural Three.js " +
	"sculpt spec. Follow your system instructions exactly. Reply with ONLY the strict JSON " +
	"spec object - the first character of the reply is { and the last is }."

payload := map[string]any{
	"fields": map[string]any{
		"subject": "red rubber ball",
		"description": "A single matte red rubber ball on a white studio background. " +
			"Uniform colour all round; no seams, no logo.",
		"detail_level": "blockout",
		"prescan": map[string]any{
			"width": 900, "height": 900, "aspect": 1.0,
			"background_removed": true,
			"palette": []any{
				map[string]any{"hex": "#d0342c", "pct": 61.4},
				map[string]any{"hex": "#8e211c", "pct": 12.7},
				map[string]any{"hex": "#f2c8c4", "pct": 3.2},
			},
			"silhouette": [][]float64{
				{0.50, 0.10}, {0.78, 0.22}, {0.90, 0.50}, {0.78, 0.78},
				{0.50, 0.90}, {0.22, 0.78}, {0.10, 0.50}, {0.22, 0.22},
			},
			"grid": [][]any{
				{nil, "#d0342c", nil},
				{"#d0342c", "#d0342c", "#d0342c"},
				{nil, "#8e211c", nil},
			},
			"symmetry":   map[string]any{"vertical": 0.98},
			"fill_ratio": 0.63,
		},
	},
	"instruction": instruction,
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	ModelAlias  string `json:"model_alias"`
}
err := call("POST", "/estimate", payload, &est)
// The body is fixed JSON apart from your measurements — a text block is enough.
String jsonPayload = """
    {
      "fields": {
        "subject": "red rubber ball",
        "description": "A single matte red rubber ball on a white studio background. Uniform colour all round; no seams, no logo.",
        "detail_level": "blockout",
        "prescan": {
          "width": 900, "height": 900, "aspect": 1.0,
          "background_removed": true,
          "palette": [
            {"hex": "#d0342c", "pct": 61.4},
            {"hex": "#8e211c", "pct": 12.7},
            {"hex": "#f2c8c4", "pct": 3.2}
          ],
          "silhouette": [
            [0.50, 0.10], [0.78, 0.22], [0.90, 0.50], [0.78, 0.78],
            [0.50, 0.90], [0.22, 0.78], [0.10, 0.50], [0.22, 0.22]
          ],
          "grid": [
            [null, "#d0342c", null],
            ["#d0342c", "#d0342c", "#d0342c"],
            [null, "#8e211c", null]
          ],
          "symmetry": {"vertical": 0.98},
          "fill_ratio": 0.63
        }
      },
      "instruction": "Reconstruct the object described in fields as a procedural Three.js sculpt spec. Follow your system instructions exactly. Reply with ONLY the strict JSON spec object - the first character of the reply is { and the last is }."
    }
    """;

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits; the model name at data.model_alias
INSTRUCTION = "Reconstruct the object described in fields as a procedural Three.js sculpt " \
              "spec. Follow your system instructions exactly. Reply with ONLY the strict " \
              "JSON spec object - the first character of the reply is { and the last is }."

payload = {
  fields: {
    subject: "red rubber ball",
    description: "A single matte red rubber ball on a white studio background. " \
                 "Uniform colour all round; no seams, no logo.",
    detail_level: "blockout",
    prescan: {
      width: 900, height: 900, aspect: 1.0,
      background_removed: true,
      palette: [
        { hex: "#d0342c", pct: 61.4 },
        { hex: "#8e211c", pct: 12.7 },
        { hex: "#f2c8c4", pct: 3.2 }
      ],
      silhouette: [
        [0.50, 0.10], [0.78, 0.22], [0.90, 0.50], [0.78, 0.78],
        [0.50, 0.90], [0.22, 0.78], [0.10, 0.50], [0.22, 0.22]
      ],
      grid: [
        [nil, "#d0342c", nil],
        ["#d0342c", "#d0342c", "#d0342c"],
        [nil, "#8e211c", nil]
      ],
      symmetry: { vertical: 0.98 },
      fill_ratio: 0.63
    }
  },
  instruction: INSTRUCTION
}

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model_alias"]}"
$instruction = "Reconstruct the object described in fields as a procedural Three.js sculpt "
             . "spec. Follow your system instructions exactly. Reply with ONLY the strict "
             . "JSON spec object - the first character of the reply is { and the last is }.";

$payload = [
    "fields" => [
        "subject"      => "red rubber ball",
        "description"  => "A single matte red rubber ball on a white studio background. "
                          . "Uniform colour all round; no seams, no logo.",
        "detail_level" => "blockout",
        "prescan"      => [
            "width" => 900, "height" => 900, "aspect" => 1.0,
            "background_removed" => true,
            "palette" => [
                ["hex" => "#d0342c", "pct" => 61.4],
                ["hex" => "#8e211c", "pct" => 12.7],
                ["hex" => "#f2c8c4", "pct" => 3.2],
            ],
            "silhouette" => [
                [0.50, 0.10], [0.78, 0.22], [0.90, 0.50], [0.78, 0.78],
                [0.50, 0.90], [0.22, 0.78], [0.10, 0.50], [0.22, 0.22],
            ],
            "grid" => [
                [null, "#d0342c", null],
                ["#d0342c", "#d0342c", "#d0342c"],
                [null, "#8e211c", null],
            ],
            "symmetry"   => ["vertical" => 0.98],
            "fill_ratio" => 0.63,
        ],
    ],
    "instruction" => $instruction,
];

$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model_alias']}\n";
const string Instruction =
    "Reconstruct the object described in fields as a procedural Three.js sculpt spec. " +
    "Follow your system instructions exactly. Reply with ONLY the strict JSON spec object " +
    "- the first character of the reply is { and the last is }.";

var payload = new {
    fields = new {
        subject = "red rubber ball",
        description = "A single matte red rubber ball on a white studio background. "
                    + "Uniform colour all round; no seams, no logo.",
        detail_level = "blockout",
        prescan = new {
            width = 900, height = 900, aspect = 1.0,
            background_removed = true,
            palette = new object[] {
                new { hex = "#d0342c", pct = 61.4 },
                new { hex = "#8e211c", pct = 12.7 },
                new { hex = "#f2c8c4", pct = 3.2 },
            },
            silhouette = new[] {
                new[] { 0.50, 0.10 }, new[] { 0.78, 0.22 },
                new[] { 0.90, 0.50 }, new[] { 0.78, 0.78 },
                new[] { 0.50, 0.90 }, new[] { 0.22, 0.78 },
                new[] { 0.10, 0.50 }, new[] { 0.22, 0.22 },
            },
            grid = new[] {
                new string?[] { null, "#d0342c", null },
                new string?[] { "#d0342c", "#d0342c", "#d0342c" },
                new string?[] { null, "#8e211c", null },
            },
            symmetry = new { vertical = 0.98 },
            fill_ratio = 0.63,
        },
    },
    instruction = Instruction,
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

A good prescan is mostly about the silhouette. Trace the alpha or background-difference mask, walk it clockwise, resample to somewhere between 16 and 48 points, and normalize by width and height — do not send raw pixel coordinates, and do not reverse the winding. Colours are the second lever: quantize to at most six buckets, drop the background bucket, and keep them coverage-sorted, because palette[0] is read as the body colour.

Step 4 — Run the reconstruction and wait for the spec

POST /run
GET /jobs/{job_id}

/run takes the same body 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. A blockout typically lands in 20–40 s and a high run with sixty parts can take two minutes, so step 5's streaming endpoint is the better default for anything with a user watching. Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — nested as output.output, and as a JSON string, so parse defensively.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: i23-$(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

# unwrap the reply once, then read it
echo "$JOB" | jq -r '.data.output.output' > spec.json

jq -r '
  "\(.meta.subject) [\(.meta.class)/\(.meta.style)] confidence \(.meta.confidence)",
  "  \(.meta.notes)",
  "",
  "PARTS (\(.parts | length))",
  (.parts[] | "  \(.id) <- \(.parent // "root")  \(.geometry.type)  \(.material.color // "-")"),
  "",
  "ANIMATIONS",
  (.animations[] | "  \(.name): \(.type) on \(.target) axis \(.axis) period \(.period)s"),
  "",
  "CAMERA  d=\(.camera.distance) el=\(.camera.elevation_deg) az=\(.camera.azimuth_deg)",
  "",
  "APPROXIMATIONS",
  (.report.approximations[] | "  - \(.)"),
  "HIDDEN ASSUMPTIONS",
  (.report.hidden_assumptions[] | "  - \(.)"),
  "CONFIDENCE BY REGION",
  (.report.per_region_confidence[] | "  \(.region): \(.confidence)")' \
  spec.json

# every detail you were promised must land on a real part
jq -e '[.detail_inventory[].mapped_to] - ([.parts[].id] + ["material:"]) | length == 0' \
  spec.json > /dev/null || echo "note: some details map to material notes, not parts"
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "i23-ball-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"]
spec = json.loads(raw) if isinstance(raw, str) else raw

meta = spec["meta"]
print(f'{meta["subject"]} [{meta["class"]}/{meta["style"]}] confidence {meta["confidence"]}')
print(" ", meta["notes"])

by_id = {p["id"]: p for p in spec["parts"]}
for p in spec["parts"]:
    parent = p["parent"] or "root"
    print(f'  {p["id"]:<18} <- {parent:<18} {p["geometry"]["type"]:<12} '
          f'{p["material"].get("color", "-")}')
    # a parent must be defined before its child
    assert p["parent"] is None or p["parent"] in by_id

for a in spec["animations"]:
    print(f'  anim {a["name"]}: {a["type"]} on {a["target"]} '
          f'axis {a["axis"]} period {a["period"]}s amp {a["amplitude"]}')

for d in spec["detail_inventory"]:
    print(f'  detail {d["detail"]} ({d["kind"]}) -> {d["mapped_to"]}')

cam = spec["camera"]
print(f'  camera d={cam["distance"]} el={cam["elevation_deg"]} az={cam["azimuth_deg"]}')

rep = spec["report"]
for a in rep["approximations"]:
    print("  approximation:", a)
for h in rep["hidden_assumptions"]:
    print("  assumption:", h)
for r in rep["per_region_confidence"]:
    print(f'  region {r["region"]}: {r["confidence"]}')

with open("spec.json", "w", encoding="utf-8") as fh:
    json.dump(spec, fh, indent=2)

if meta["confidence"] < 0.4:
    raise SystemExit("low-confidence reconstruction — review before using")
import { writeFileSync } from "node:fs";

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;
const spec = typeof raw === "string" ? JSON.parse(raw) : raw;

const { meta } = spec;
console.log(`${meta.subject} [${meta.class}/${meta.style}] confidence ${meta.confidence}`);
console.log(" ", meta.notes);

const ids = new Set();
for (const p of spec.parts) {
  if (p.parent && !ids.has(p.parent)) throw new Error(`forward parent ref: ${p.id}`);
  ids.add(p.id);
  console.log(`  ${p.id} <- ${p.parent ?? "root"}  ${p.geometry.type}  ${p.material.color ?? "-"}`);
}
for (const a of spec.animations) {
  console.log(`  anim ${a.name}: ${a.type} on ${a.target} axis ${a.axis} period ${a.period}s`);
}
for (const d of spec.detail_inventory) {
  console.log(`  detail ${d.detail} (${d.kind}) -> ${d.mapped_to}`);
}
console.log(`  camera d=${spec.camera.distance} el=${spec.camera.elevation_deg} az=${spec.camera.azimuth_deg}`);
for (const a of spec.report.approximations) console.log("  approximation:", a);
for (const h of spec.report.hidden_assumptions) console.log("  assumption:", h);
for (const r of spec.report.per_region_confidence) {
  console.log(`  region ${r.region}: ${r.confidence}`);
}

writeFileSync("spec.json", JSON.stringify(spec, null, 2));
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)
}

// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Part struct {
	ID       string          `json:"id"`
	Parent   *string         `json:"parent"`
	Geometry json.RawMessage `json:"geometry"` // {"type": ...} plus per-type params
	Material map[string]any  `json:"material"`
	Position [3]float64      `json:"position"`
	Rotation [3]float64      `json:"rotation"` // Euler XYZ, radians
	Scale    [3]float64      `json:"scale"`
	Note     string          `json:"note"`
}
type Spec struct {
	Meta struct {
		Subject, Class, Style, Notes string
		Confidence                   float64 `json:"confidence"`
	} `json:"meta"`
	DetailInventory []struct {
		Detail, Kind string
		MappedTo     string `json:"mapped_to"`
	} `json:"detail_inventory"`
	PaletteUsed []string `json:"palette_used"`
	Parts       []Part   `json:"parts"`
	Animations  []struct {
		Name, Target, Type, Axis string
		Period, Amplitude        float64
	} `json:"animations"`
	Camera struct {
		Distance     float64 `json:"distance"`
		ElevationDeg float64 `json:"elevation_deg"`
		AzimuthDeg   float64 `json:"azimuth_deg"`
	} `json:"camera"`
	Report struct {
		Approximations      []string `json:"approximations"`
		HiddenAssumptions   []string `json:"hidden_assumptions"`
		PerRegionConfidence []struct {
			Region     string  `json:"region"`
			Confidence float64 `json:"confidence"`
		} `json:"per_region_confidence"`
	} `json:"report"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var spec Spec
json.Unmarshal([]byte(wrapper.Output), &spec)

fmt.Printf("%s [%s/%s] confidence %.2f\n",
	spec.Meta.Subject, spec.Meta.Class, spec.Meta.Style, spec.Meta.Confidence)
seen := map[string]bool{}
for _, p := range spec.Parts {
	if p.Parent != nil && !seen[*p.Parent] {
		log.Fatalf("forward parent reference at %s", p.ID)
	}
	seen[p.ID] = true
	fmt.Printf("  %-18s %v\n", p.ID, p.Material["color"])
}
for _, a := range spec.Animations {
	fmt.Printf("  anim %s: %s on %s (%gs)\n", a.Name, a.Type, a.Target, a.Period)
}
os.WriteFile("spec.json", []byte(wrapper.Output), 0o644)
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 spec is at data.output.output as a JSON string — parse it again, then read:
//   meta {subject, class, style, confidence, notes}
//   detail_inventory[] {detail, kind, mapped_to}
//   palette_used[]  — every hex the model actually used
//   parts[] {id, parent, geometry{type,…}, material{color,roughness,metalness,…},
//            position[3], rotation[3] (radians), scale[3], note}
//   animations[] {name, target, type, axis, period, amplitude}
//   camera {distance, elevation_deg, azimuth_deg}
//   report {approximations[], hidden_assumptions[],
//           per_region_confidence[]{region, confidence}}
// Walk parts in array order: a part's `parent` is always defined earlier, so a
// single pass can build the scene graph without a second lookup.
//   Files.writeString(Path.of("spec.json"), specJson);
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"]
spec = raw.is_a?(String) ? JSON.parse(raw) : raw

meta = spec["meta"]
puts "#{meta["subject"]} [#{meta["class"]}/#{meta["style"]}] confidence #{meta["confidence"]}"
puts "  #{meta["notes"]}"

seen = []
spec["parts"].each do |p|
  raise "forward parent ref at #{p["id"]}" if p["parent"] && !seen.include?(p["parent"])
  seen << p["id"]
  puts "  #{p["id"]} <- #{p["parent"] || "root"}  #{p["geometry"]["type"]}  #{p["material"]["color"]}"
end
spec["animations"].each do |a|
  puts "  anim #{a["name"]}: #{a["type"]} on #{a["target"]} axis #{a["axis"]} period #{a["period"]}s"
end
spec["detail_inventory"].each { |d| puts "  detail #{d["detail"]} (#{d["kind"]}) -> #{d["mapped_to"]}" }
spec["report"]["hidden_assumptions"].each { |h| puts "  assumption: #{h}" }

File.write("spec.json", JSON.pretty_generate(spec))
$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"];
$spec = is_string($raw) ? json_decode($raw, true) : $raw;

$meta = $spec["meta"];
echo "{$meta['subject']} [{$meta['class']}/{$meta['style']}] confidence {$meta['confidence']}\n";
echo "  {$meta['notes']}\n";

$seen = [];
foreach ($spec["parts"] as $p) {
    if ($p["parent"] !== null && !in_array($p["parent"], $seen, true)) {
        throw new Exception("forward parent reference at {$p['id']}");
    }
    $seen[] = $p["id"];
    $parent = $p["parent"] ?? "root";
    echo "  {$p['id']} <- {$parent}  {$p['geometry']['type']}  " .
         ($p["material"]["color"] ?? "-") . "\n";
}
foreach ($spec["animations"] as $a) {
    echo "  anim {$a['name']}: {$a['type']} on {$a['target']} period {$a['period']}s\n";
}
foreach ($spec["report"]["hidden_assumptions"] as $h) {
    echo "  assumption: $h\n";
}

file_put_contents("spec.json", json_encode($spec, JSON_PRETTY_PRINT));
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 rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var spec = doc.RootElement;

var meta = spec.GetProperty("meta");
Console.WriteLine($"{meta.GetProperty("subject")} " +
                  $"[{meta.GetProperty("class")}/{meta.GetProperty("style")}] " +
                  $"confidence {meta.GetProperty("confidence")}");

var seen = new HashSet<string>();
foreach (var p in spec.GetProperty("parts").EnumerateArray())
{
    var id = p.GetProperty("id").GetString()!;
    var parent = p.GetProperty("parent").ValueKind == JsonValueKind.Null
        ? null : p.GetProperty("parent").GetString();
    if (parent != null && !seen.Contains(parent))
        throw new Exception($"forward parent reference at {id}");
    seen.Add(id);
    Console.WriteLine($"  {id} <- {parent ?? "root"}  " +
                      $"{p.GetProperty("geometry").GetProperty("type")}");
}
foreach (var a in spec.GetProperty("animations").EnumerateArray())
{
    Console.WriteLine($"  anim {a.GetProperty("name")}: {a.GetProperty("type")} " +
                      $"on {a.GetProperty("target")}");
}

await File.WriteAllTextAsync("spec.json", rawText!);

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 before it gives up on a reply.

The sculpt spec — output schema

One JSON object, always the same shape, with every array present (an empty animations list is a perfectly valid answer for a static object). Every colour in it traces back to your prescan.palette; every proportion traces back to your silhouette and aspect; everything a single view could not reveal — the far side, the underside, a hidden mechanism — is written down in report.hidden_assumptions rather than passed off as observation.

FieldTypeMeaning
metaobject{subject, class, style, confidence, notes}. class is object | character | hybrid — characters get head-unit proportions and a limb hierarchy, objects follow the hard-surface track. style is realistic | stylized | low-poly. confidence is a 0..1 likeness estimate, and it is meant to be honest: a vague subject with a coarse silhouette earns 0.4, not 0.9. notes is a sentence or two on the approach taken.
detail_inventoryarray{detail, kind, mapped_to} — the identity-defining small features the subject needs in order to read as itself. kind is one of gloss, bevel, fastener, linework, contour, seam, stain, scratch, decal, emissive, hole, groove, ridge. mapped_to is a real parts[].id, or a material note prefixed material:. Nothing is listed and then silently dropped — an entry that could not be expressed appears in report.approximations instead, which makes this the field to assert on in an automated check.
palette_usedstring[]Every hex the reconstruction actually applied, lowercase #rrggbb. Diff it against your prescan palette to see which measured colours survived.
partsarrayThe core deliverable — the scene graph, in dependency order. Columns below. Length is capped by detail_level: 14, 32 or 60.
animationsarray{name, target, type, axis, period, amplitude}, only where motion is natural to the object. type is spin (continuous, period in seconds per revolution), bob (vertical sine, amplitude in units), swing (rotation sine, amplitude in radians) or pulse (emissive intensity sine). target is a part id, usually a group pivot.
cameraobject{distance, elevation_deg, azimuth_deg} — the framing that shows this object best, in orbit terms around the origin.
reportobject{approximations[], hidden_assumptions[], per_region_confidence[]}. Read this before you trust the geometry: approximations lists what was simplified or could not be expressed, hidden_assumptions lists what was invented because one view cannot show it, and per_region_confidence gives {region, confidence} per major part so you can see exactly which end of the model is guesswork.

Each entry in parts:

ColumnMeaning
idUnique kebab-case, descriptive — front-wheel, never part7.
parentAnother part's id, or null for a root. The parent always appears earlier in the array, so one forward pass builds the whole graph. Child transforms are relative to the parent.
geometry{"type": …} plus that type's parameters — see the vocabulary table below.
material{color, roughness, metalness} and, where relevant, opacity with transparent, emissive with emissiveIntensity, side, flatShading. Families follow physical sense: painted plastic roughness 0.4–0.7 / metalness 0; bare metal 0.05–0.35 / 0.9–1.0; rubber and fabric 0.8–1.0 / 0; glass 0.0–0.15 with opacity 0.3–0.6.
position[x, y, z], relative to the parent.
rotation[x, y, z] Euler XYZ in radians, not degrees.
scale[x, y, z] multipliers — how a sphere becomes an ellipsoid.
noteA short human note on the part's role.

geometry.type is one of thirteen values, and nothing else:

typeParametersUsed for
groupnonePivots, articulation joints, part clusters. No mesh — this is what makes a model animation-ready.
boxw, h, dHousings, slabs, frames.
spherer; opt widthSegments, heightSegmentsBalls and domes; scale turns them into ellipsoids.
cylinderrTop, rBottom, h; opt radialSegments, openEndedShafts, cans, tapered bodies.
coner, h; opt radialSegmentsTips, spouts, funnels.
torusr, tube; opt arc in radiansRims, rings, partial-arc handles.
capsuler, hLimbs, grips, soft bars.
planew, hDecals and flat panels; pairs with material.side: "double".
ringinner, outerWashers, flat rims.
latheprofile [[x, y], …] with x ≥ 0 and y up; opt segmentsAny surface of revolution — vases, bottles, wheels, bells. Reached for whenever symmetry.vertical is high.
extrudeshape [[x, y], …] closed outline; opt holes, depth, bevel {thickness, size}Flat-profiled parts: brackets, letters, blades, gears.
tubepath [[x, y, z], …] with at least 3 points, radius; opt closed, tubularSegmentsWires, pipes, curved handles, bike frames.
icosahedronr; opt detailRocks and low-poly accents.

Units. The whole model fits inside a 2-unit-tall bounding volume centred near the origin and resting on y = -1, so it always lands on the ground plane of whatever scene you drop it into, and the camera.distance you get back is in those same units. Wider-than-tall subjects spend their budget in x and z instead of growing past the box.

A complete, realistic result for the red-ball example above:

{
  "meta": {
    "subject": "red rubber ball",
    "class": "object",
    "style": "realistic",
    "confidence": 0.88,
    "notes": "Near-perfect circular silhouette and a vertical symmetry of 0.98 make this a
              single sphere; the darker palette entry is read as contact shading at the
              bottom rather than a second material."
  },
  "detail_inventory": [
    { "detail": "matte rubber surface, no specular highlight",
      "kind": "gloss", "mapped_to": "material:high roughness on ball-body" }
  ],
  "palette_used": ["#d0342c"],
  "parts": [
    {
      "id": "ball-body", "parent": null,
      "geometry": { "type": "sphere", "r": 1.0, "widthSegments": 48, "heightSegments": 32 },
      "material": { "color": "#d0342c", "roughness": 0.92, "metalness": 0.0 },
      "position": [0, 0, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1],
      "note": "the whole object — a unit sphere resting on the ground plane"
    }
  ],
  "animations": [],
  "camera": { "distance": 3.6, "elevation_deg": 12, "azimuth_deg": 25 },
  "report": {
    "approximations": [
      "The three palette entries are treated as one material; #8e211c is shading, not paint."
    ],
    "hidden_assumptions": [
      "Assumed the unseen hemisphere matches the visible one — nothing in the silhouette
       contradicts it, but a printed logo on the far side would be missed."
    ],
    "per_region_confidence": [
      { "region": "ball-body", "confidence": 0.88 }
    ]
  }
}

This is a reconstruction, not a measurement: it is built from the facts you sent about one view, not from the object. Read report.hidden_assumptions and per_region_confidence before you ship the geometry anywhere it matters — the far side of anything is, by construction, an educated guess.

Step 5 — Stream the spec as it is built

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show real progress instead of a spinner. That matters here: a sixty-part high reconstruction is a long reply, and the parts arrive in dependency order, so a client can light up the blockout while the detail passes are still being written. This app's own progress panel is this endpoint, which is why it is the preferred lane for anything interactive. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
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, since the total is not known in advance. The app advances its step list by watching for the "detail_inventory", "parts", "animations", "camera" and "report" keys as they arrive, and by counting "id": occurrences against the part budget.
pending{job_id, status}The job is still queued or running when the stream ends early — fall back to polling /jobs/{job_id} as in step 4.
done{job_id, status, charged_credits, output}The final, authoritative result — read the spec 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: i23-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"meta\":{\"subject\":\"red rubber ball\""}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":312,"output":{"output":"{...}"}}
import json, requests

result = None
seen_parts = 0
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "i23-ball-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":
                seen_parts += data["text"].count('"id"')   # live progress
                print(f"\rparts so far: {seen_parts}", end="", flush=True)
            elif event == "done":
                result = data
            elif event == "pending":
                print("\nstill running — poll /jobs/" + data["job_id"])
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

spec = json.loads(result["output"]["output"])             # authoritative
print("\ncharged:", result["charged_credits"], "-", spec["meta"]["subject"])
print("parts:", len(spec["parts"]), "animations:", len(spec["animations"]))
for h in spec["report"]["hidden_assumptions"]:
    print("  assumption:", h)
with open("spec.json", "w", encoding="utf-8") as fh:
    json.dump(spec, fh, indent=2)
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, chars = 0;

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") chars += data.text.length;         // live progress
    if (name === "pending") console.log("still running:", data.job_id);
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const spec = JSON.parse(done.output.output);
console.log(`${done.charged_credits} credits, ${chars} chars - ${spec.meta.subject}`);
console.log(`  ${spec.parts.length} parts, ${spec.animations.length} animations`);
for (const h of spec.report.hidden_assumptions) console.log("  assumption:", h);
writeFileSync("spec.json", JSON.stringify(spec, null, 2));
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", "i23-ball-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), 4*1024*1024)
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 "pending":
			fmt.Println("\nstill running:", data["job_id"])
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the spec JSON —
// unmarshal it into the Spec struct from step 4, then write it to spec.json.
// 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", "i23-ball-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 ("pending".equals(event)) System.out.println("still running: " + data);
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// meta, detail_inventory[], palette_used[], parts[], animations[], camera and report.
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"] = "i23-ball-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 "pending" then puts "\nstill running: #{data["job_id"]}"
          when "done"    then done = data
          when "error"   then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

spec = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{spec["meta"]["subject"]}"
puts "  #{spec["parts"].length} parts, #{spec["animations"].length} animations"
spec["report"]["hidden_assumptions"].each { |h| puts "  assumption: #{h}" }
File.write("spec.json", JSON.pretty_generate(spec))
$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: i23-ball-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 === "pending") { echo "\nstill running: {$data['job_id']}\n"; }
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$spec = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$spec['meta']['subject']}\n";
echo "  " . count($spec["parts"]) . " parts, " . count($spec["animations"]) . " animations\n";
foreach ($spec["report"]["hidden_assumptions"] as $h) {
    echo "  assumption: $h\n";
}
file_put_contents("spec.json", json_encode($spec, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "i23-ball-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 == "pending") Console.WriteLine($"still running: {data}");
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var specDoc = JsonDocument.Parse(text!);
var spec = specDoc.RootElement;
Console.WriteLine($"{spec.GetProperty("meta").GetProperty("subject")}: " +
                  $"{spec.GetProperty("parts").GetArrayLength()} parts");
foreach (var h in spec.GetProperty("report").GetProperty("hidden_assumptions").EnumerateArray())
    Console.WriteLine($"  assumption: {h}");
await File.WriteAllTextAsync("spec.json", text!);

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.

Rendering what comes back

The spec is declarative, and that is the point: it is data describing a scene graph, not code. Nothing in a reply is ever evaluated — this app does not eval, new Function or otherwise execute model output, and neither should you. Rendering is a plain walk over parts in array order, and because a parent is always defined before its children, one pass is enough:

for each part in spec.parts:
    node = geometry.type == "group"
             ? new THREE.Group()
             : new THREE.Mesh(makeGeometry(part.geometry),
                              new THREE.MeshStandardMaterial(part.material))
    node.position.set(...part.position)
    node.rotation.set(...part.rotation)      # radians, Euler XYZ
    node.scale.set(...part.scale)
    (part.parent ? nodes[part.parent] : root).add(node)
    nodes[part.id] = node

# then: animations[] drive nodes[target] on each frame,
#       camera{distance, elevation_deg, azimuth_deg} sets the orbit.

You do not have to write that dispatcher yourself. Run the same subject through the web app and open the result card's Three.js code tab: it emits ready-to-run factory source for the spec — a function per geometry type, the material setup, the parent wiring and the animation loop — which you can copy into your own project and then feed with any spec of the same shape, including the ones your API runs produce. The Spec JSON tab next to it downloads exactly the object /run returns, so a spec captured from the API and a spec captured from the UI are interchangeable.

Two guardrails worth keeping in your own renderer: validate geometry.type against the thirteen allowed values and skip anything else rather than improvising, and treat material as a whitelist of known keys. A spec is untrusted input like any other API response.