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.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest starting a metered run). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
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
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
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
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
| Field | Type | Notes |
|---|---|---|
subject | string, required | A 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. |
description | string, optional | Free-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_level | string | blockout | 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. |
prescan | object | What your client measured from the image. The model never sees pixels, so this is its whole view of the picture. Columns below. |
fields.prescan
| Key | Type | Meaning |
|---|---|---|
width, height | int | Pixel dimensions of the image you measured. |
aspect | float | width / height. The reconstruction matches this: wider-than-tall subjects extend in x/z rather than y. |
background_removed | bool | true 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. |
palette | array, ≤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. |
silhouette | array, ≤48 | The 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. |
grid | 3×3 array | Row-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. |
symmetry | object | {"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_ratio | float 0..1 | Fraction 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
/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.
| Field | Type | Meaning |
|---|---|---|
meta | object | {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_inventory | array | {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_used | string[] | Every hex the reconstruction actually applied, lowercase #rrggbb. Diff it against your prescan palette to see which measured colours survived. |
parts | array | The core deliverable — the scene graph, in dependency order. Columns below. Length is capped by detail_level: 14, 32 or 60. |
animations | array | {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. |
camera | object | {distance, elevation_deg, azimuth_deg} — the framing that shows this object best, in orbit terms around the origin. |
report | object | {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:
| Column | Meaning |
|---|---|
id | Unique kebab-case, descriptive — front-wheel, never part7. |
parent | Another 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. |
note | A short human note on the part's role. |
geometry.type is one of thirteen values, and nothing else:
| type | Parameters | Used for |
|---|---|---|
group | none | Pivots, articulation joints, part clusters. No mesh — this is what makes a model animation-ready. |
box | w, h, d | Housings, slabs, frames. |
sphere | r; opt widthSegments, heightSegments | Balls and domes; scale turns them into ellipsoids. |
cylinder | rTop, rBottom, h; opt radialSegments, openEnded | Shafts, cans, tapered bodies. |
cone | r, h; opt radialSegments | Tips, spouts, funnels. |
torus | r, tube; opt arc in radians | Rims, rings, partial-arc handles. |
capsule | r, h | Limbs, grips, soft bars. |
plane | w, h | Decals and flat panels; pairs with material.side: "double". |
ring | inner, outer | Washers, flat rims. |
lathe | profile [[x, y], …] with x ≥ 0 and y up; opt segments | Any surface of revolution — vases, bottles, wheels, bells. Reached for whenever symmetry.vertical is high. |
extrude | shape [[x, y], …] closed outline; opt holes, depth, bevel {thickness, size} | Flat-profiled parts: brackets, letters, blades, gears. |
tube | path [[x, y, z], …] with at least 3 points, radius; opt closed, tubularSegments | Wires, pipes, curved handles, bike frames. |
icosahedron | r; opt detail | Rocks 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
/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.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it; the accumulated length is your only progress signal, 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.