Drive Full Loft from your own code
The one metered task, loft-check, is available over HTTP. It takes 1 puffer jacket (its fill, what its care label allows, when it was last washed) and up to 6 signs you can see or feel, each with the page's route, and returns each sign's likely cause, a first move and how to check it worked, plus the order to try them.
The division of labour first: a fixed rule routes each sign and the page picks the sign to try first; the model explains them. After a run the page checks that every route matches the rule, that the order starts with the page's try-first sign, that every number comes from the check and that no ids leak into the prose.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"full-loft"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, jacket, fill, care_label, last_washed, signs, facts) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: a reading is metered and needs a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field, the input and the routes
Every run sends task: loft-check (the one task is loft-check); a missing or different one is still answered as loft-check. The rule, first match wins: still damp after a soaking dry; fill escaping from a hole or seam patch; a single poking feather tuck; lumps or clumps dry; grubby or shiny patches spot; a cold spot fluff; flat or thin with synthetic fill or after a wash within the last year fluff; any other flat sign or a stale smell wash, or pro when the label says dry clean only. Try first: a damp sign always first, otherwise the lightest route (tuck, fluff, spot, patch, dry, wash, pro), the earlier row on a tie.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always loft-check. |
jacket | yes | The jacket in the person's words, up to 60 characters. |
fill | yes | down, down and feather, synthetic fill or not sure. |
care_label | yes | machine wash allowed, hand wash only, dry clean only or no label or cannot read it. |
last_washed | yes | within the last year, over a year ago or never, or not sure. |
signs | yes | 1 line per sign: item 1 | where: chest | sign: flat or thin, no lumps | noticed: ... | page route: wash. |
facts | yes | The page's lines: try first:, heaviest step:, routes:, needs a wash or a cleaner:. |
The reply
job.output.output is a JSON string:
{"task": "loft-check", "headline": "...", "items": [{"item": 1, "route": "wash", "likely_cause": "...", "first_move": "...", "check_after": "..."}, ...], "order": "...", "closing": "..."}
| Field | What it holds |
|---|---|
headline | How many signs need a wash or a cleaner, at most 16 words. |
items | 1 entry per sign, in order: item, route (copied from the page), likely_cause (at most 25 words), first_move (at most 35 words) and check_after (at most 20 words). |
order | The order to try the fixes, starting with the try-first sign, at most 40 words. |
closing | 1 light sentence, at most 20 words. |
The page then checks the reply (LoftKit.check) and lists any disagreement under "The page's check".
Worked examples - every route
These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free.
Flat chest, poking feather, shiny cuffs, cold spot - wash, tuck, spot, fluff; the tuck comes first
Input:
{
"task": "loft-check",
"jacket": "long black puffer",
"fill": "down",
"care_label": "machine wash allowed",
"last_washed": "over a year ago",
"signs": "item 1 | where: chest | sign: flat or thin, no lumps | noticed: never really sprang back after summer in the wardrobe | page route: wash\nitem 2 | where: sleeves or cuffs | sign: a single feather or quill poking through | noticed: nothing added | page route: tuck\nitem 3 | where: sleeves or cuffs | sign: grubby or shiny patches | noticed: shiny cuffs | page route: spot\nitem 4 | where: back | sign: a cold spot when worn | noticed: nothing added | page route: fluff",
"facts": "try first: item 2 (tuck, the lightest fix here)\nheaviest step: item 1 (wash)\nroutes: tuck 1, fluff 1, spot 1, patch 0, dry 0, wash 1, pro 0\nneeds a wash or a cleaner: 1 of 4 signs"
}
Reply:
{
"task": "loft-check",
"headline": "Just 1 of 4 signs on your long black puffer needs a wash or a cleaner.",
"items": [
{
"item": 1,
"route": "wash",
"likely_cause": "Probably the down at your chest hasn't regained loft since summer in the wardrobe, matching no wash in over a year.",
"first_move": "Give the jacket a gentle wash on a cool cycle, either in the machine or using your hands, with a soap made for down, then dry it fully so the chest regains its loft.",
"check_after": "Once fully dry, feel the chest again for springy loft instead of the flat, thin feel."
},
{
"item": 2,
"route": "tuck",
"likely_cause": "Probably a stray down feather or quill has worked its way out through the fabric at your sleeve or cuff, not a wider tear.",
"first_move": "At that sleeve or cuff, reach inside the fabric with a fingertip and gently push the stray feather back through, rather than pulling it out from outside, since pulling only enlarges the opening.",
"check_after": "Check that area again soon after: if no feather or quill tip is visible or felt, it worked."
},
{
"item": 3,
"route": "spot",
"likely_cause": "Probably built-up body oils and grime have made your cuffs grubby and shiny there, not a wider soiling needing a full wash.",
"first_move": "On those shiny cuffs, wipe the grubby area with a damp cloth and a touch of mild soap, working gently, then leave it to air dry completely.",
"check_after": "Once dry, look at the cuffs for less shine or grime; repeat lightly if any dull mark remains."
},
{
"item": 4,
"route": "fluff",
"likely_cause": "Probably some of the down fill near your back has shifted toward a section of the jacket, leaving that back area thinner and less warm.",
"first_move": "Give the jacket a firm shake, then use your hands to loosen and redistribute the fill into the back where it feels empty, then let the jacket hang freely.",
"check_after": "Put the jacket on again and feel that section of your back for even warmth instead of a cold spot."
}
],
"order": "Start with item 2, since tucking the feather back in is the simplest first step, then move to item 3, item 4, and finish with item 1, the biggest job here.",
"closing": "Your long black puffer has quick fixes within reach, so start with that stray feather and see how it feels."
}
Soaked parka, clumps, a small tear, a stale hood - dry, dry, patch, wash; drying comes first
Input:
{
"task": "loft-check",
"jacket": "hooded parka",
"fill": "down and feather",
"care_label": "hand wash only",
"last_washed": "within the last year",
"signs": "item 1 | where: all over | sign: got soaked and is still damp | noticed: caught in heavy rain yesterday | page route: dry\nitem 2 | where: back | sign: lumps or clumps of fill | noticed: hard lumps low on the back | page route: dry\nitem 3 | where: sleeves or cuffs | sign: fill escaping from a hole or seam | noticed: small tear by the elbow | page route: patch\nitem 4 | where: hood or collar | sign: smells musty or stale | noticed: nothing added | page route: wash",
"facts": "try first: item 1 (dry, damp fill dries before anything else)\nheaviest step: item 4 (wash)\nroutes: tuck 0, fluff 0, spot 0, patch 1, dry 2, wash 1, pro 0\nneeds a wash or a cleaner: 1 of 4 signs"
}
Reply:
{
"task": "loft-check",
"headline": "1 of 4 signs need a wash or a cleaner for your hooded parka.",
"items": [
{
"item": 1,
"route": "dry",
"likely_cause": "Heavy rain yesterday most likely soaked the down and feather fill all over, so it's still damp and needs full drying before anything else.",
"first_move": "Hang the hooded parka in a spot with plenty of airflow, turning it from time to time so it dries right through.",
"check_after": "Feel all over for cool or damp patches and check none remain before moving on."
},
{
"item": 2,
"route": "dry",
"likely_cause": "The hard lumps low on your back are most likely down and feather fill that clumped together after drying unevenly.",
"first_move": "Hang the hooded parka somewhere airy and work the hard lumps apart with your hands every little while until the back feels evenly filled, not hard in patches.",
"check_after": "Feel the back again - it should feel evenly soft, with no hard lumps left."
},
{
"item": 3,
"route": "patch",
"likely_cause": "A small tear by the elbow on the sleeves or cuffs is most likely letting fill escape from inside.",
"first_move": "Close the small tear by the elbow on the sleeves or cuffs using a patch or a bit of careful stitching so the fill stays put.",
"check_after": "Check the tear by the elbow for any fresh down or feather poking through before wearing it again."
},
{
"item": 4,
"route": "wash",
"likely_cause": "The musty or stale smell at your hood or collar is probably moisture that has built up in the fill.",
"first_move": "Wash the hooded parka by hand in cool water, giving the hood or collar extra attention with a gentle soap meant for down, then rinse it well and dry it fully before wearing again.",
"check_after": "Once it's dry, smell near the hood or collar again to see if the musty smell has gone."
}
],
"order": "Start with item 1 because the fill is still damp and needs to dry before anything else, then patch item 3, work on item 2, and finish washing with item 4.",
"closing": "Give your hooded parka a little care, starting with the damp area first - the rest can follow."
}
Dry-clean-only, flat and stale, a poking quill - pro, pro, tuck
Input:
{
"task": "loft-check",
"jacket": "cream cropped puffer",
"fill": "down",
"care_label": "dry clean only",
"last_washed": "never, or not sure",
"signs": "item 1 | where: all over | sign: flat or thin, no lumps | noticed: nothing added | page route: pro\nitem 2 | where: hood or collar | sign: smells musty or stale | noticed: collar smells stale | page route: pro\nitem 3 | where: hem | sign: a single feather or quill poking through | noticed: quill keeps poking out near the zip | page route: tuck",
"facts": "try first: item 3 (tuck, the lightest fix here)\nheaviest step: item 1 (pro)\nroutes: tuck 1, fluff 0, spot 0, patch 0, dry 0, wash 0, pro 2\nneeds a wash or a cleaner: 2 of 3 signs"
}
Reply:
{
"task": "loft-check",
"headline": "2 of 3 signs on this cream cropped puffer need a wash or a cleaner.",
"items": [
{
"item": 1,
"route": "pro",
"likely_cause": "All over, the down has probably thinned evenly with no lumps, likely from going a long time with no known wash.",
"first_move": "With the label allowing only a cleaner's help, drop the cream cropped puffer off with a cleaner who works with down, covering it all over.",
"check_after": "After the cleaner returns it, check that it feels fuller and less flat all over."
},
{
"item": 2,
"route": "pro",
"likely_cause": "The collar smells stale, probably because odour has built up in the down fill with no known wash to clear it.",
"first_move": "Take it to a cleaner used to down jackets, pointing out the stale smell at the hood or collar for them to treat.",
"check_after": "Once the cleaner has treated it, check that the hood or collar no longer smells musty or stale."
},
{
"item": 3,
"route": "tuck",
"likely_cause": "Near the zip at the hem, a quill from the down fill has probably worked through the fabric, which is why it keeps poking out.",
"first_move": "At the hem near the zip, gently push the quill back through the fabric from the inside, rather than pulling it free, to keep the hole from growing.",
"check_after": "Look at that spot on the hem to see the quill stays tucked in and doesn't poke back out."
}
],
"order": "Start with item 3, tucking the quill back in since it's the lightest fix here, then plan a cleaner's visit to cover item 2 and item 1.",
"closing": "Tucking that quill back in at the hem is a simple place to start with the cream cropped puffer."
}
Build the input yourself
Load the engine in node (global.window = global; require("./loftkit.js")), then:
const K = LoftKit;
const built = K.buildInput(K.form({ jacket: "long black puffer", fill: "down", label: "machine", washed: "old",
items: [{ where: "chest", sign: "flat", note: "" }, { where: "sleeves", sign: "feather", note: "" }] }));
built.input.facts; // the page's lines: try first, heaviest step, route counts
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// built.facts.items[0].route // the route the page's rule gives
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, "my jacket") // the check as Markdown
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://full-loft.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://full-loft.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// full-loft.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://full-loft.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://full-loft.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// WhatWasThat.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class WhatWasThat {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://full-loft.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://full-loft.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://full-loft.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://full-loft.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"full-loft"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "full-loft"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "full-loft" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "full-loft"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "full-loft"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "full-loft" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "full-loft"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "full-loft" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "loft-check", "jacket": "long black puffer", "fill": "down", "care_label": "machine wash allowed", "last_washed": "over a year ago", "signs": "item 1 | where: chest | ...", "facts": "try first: item 1 ..."}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "loft-check", "jacket": "long black puffer", "fill": "down", "care_label": "machine wash allowed", "last_washed": "over a year ago", "signs": "item 1 | where: chest | ...", "facts": "try first: item 1 ..."}
# - see the field table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "loft-check", jacket, fill, care_label, last_washed, signs, facts} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses full-loft:<task>:<inputHash>:a<attempt> (the one task is loft-check), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="full-loft:loft-check:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "full-loft:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("order"))
import { createHash } from "node:crypto";
const key = `full-loft:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.order);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "full-loft:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "full-loft:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "full-loft:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["order"]].inspect
$key = "full-loft:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", json_encode($reply["order"]), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"full-loft:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: full-loft:loft-check:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "loft-check", "jacket": "long black puffer", "fill": "down", "care_label": "machine wash allowed", "last_washed": "over a year ago", "signs": "item 1 | where: chest | ...", "facts": "try first: item 1 ..."}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "full-loft:loft-check:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `full-loft:loft-check:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "full-loft:loft-check:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "full-loft:loft-check:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "full-loft:loft-check:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: full-loft:loft-check:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"full-loft:loft-check:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);