Drive Lights Low from your own code
The one metered task, light-plan, is available over HTTP. It takes up to 8 lights a home shows after dark - outdoor lamps or rooms seen through a window - each with its colour, spread, hours, purpose and pull, plus the page's fixes and verdict, and returns a summary, 1 reason and 1 first step per light, and a line on keeping the brightest light useful.
The division of labour first: a fixed rule scores every light and picks each fix and the verdict; the model explains them and writes the first steps. After a run the page checks that the verdict and every fix match its scores, that every light comes back in order, that the summary names the brightest pull, that every number comes from the lights 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":"lights-low"}), 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, place, lights, 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 plan 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 verdict
Every run sends task: light-plan (the one task is light-plan); a missing or different one is still answered as light-plan. Each setting scores 1 to 3 and a light's pull is colour x spread x hours (1 to 27, a rough guide). Pull 4 or less is keep; otherwise the highest setting is lowered to 1 (ties: spread, then hours, then colour), giving aim-down or curtain (spread), timer (hours) or warm-bulb (colour). The verdict, in order: any pull 18 or more beacon; any pull 8 or more glow; otherwise low.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always light-plan. |
place | yes | The home in the person's words, up to 60 characters, or a home. |
lights | yes | 1 line per light: item 1 | outdoor light | name: ... | colour: cool or bluish white | spread: bare bulb or points up | on: all night | for: ... | pull: 27 (3 x 3 x 3). |
facts | yes | The page's lines: 1 light N: line per light (pull, fix, reason, after the fix), then verdict:, brightest pull: and total pull:. |
retry_note | no | Sent only by the page's single reformat retry. |
The reply
{"task": "light-plan", "verdict": "beacon", "summary": "...", "lights": [{"item": 1, "fix": "aim-down", "why": "...", "first_step": "..."}], "still_useful": "...", "closing": "..."}
| Field | What it holds |
|---|---|
verdict | The page's verdict, copied: beacon, glow or low. |
summary | Names the brightest pull as an item number and gives the total pull now and after every fix, at most 35 words. |
lights | 1 entry per light, in order: item, fix (copied from the page), why (at most 20 words) and first_step (at most 25 words). |
still_useful | How the brightest light still does its job after its fix, at most 30 words. |
closing | 1 friendly sentence, at most 20 words. |
The page then checks the reply (GlowKit.check) and lists any disagreement under "The page's check".
Worked examples - every verdict
These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free.
Corner house, porch lamp on all night - total pull 49, 19 after the fixes: beacon
Input:
{
"task": "light-plan",
"place": "corner house with a front garden",
"lights": "item 1 | outdoor light | name: porch lamp by the front door | colour: cool or bluish white | spread: bare bulb or points up | on: all night | for: finding keys | pull: 27 (3 x 3 x 3)\nitem 2 | room seen through a window | name: kitchen window over the sink | colour: plain white | spread: no curtain | on: evenings, off at bedtime | for: washing up | pull: 12 (2 x 3 x 2)\nitem 3 | outdoor light | name: floodlight on the shed | colour: cool or bluish white | spread: shines sideways | on: only when a sensor sees movement | for: putting the bins out | pull: 6 (3 x 2 x 1)\nitem 4 | room seen through a window | name: landing window | colour: warm or amber | spread: thin or half-open curtain | on: evenings, off at bedtime | for: going up to bed | pull: 4 (1 x 2 x 2)",
"facts": "light 1: pull 27, strong | fix: aim-down | reason: its spread setting scores highest | after the fix: 9\nlight 2: pull 12, strong | fix: curtain | reason: its spread setting scores highest | after the fix: 4\nlight 3: pull 6, some | fix: warm-bulb | reason: its colour setting scores highest | after the fix: 2\nlight 4: pull 4, faint | fix: keep | reason: pull 4 or less | after the fix: 4\nverdict: beacon | reason: a light pulls 18 or more\nbrightest pull: item 1 (pull 27)\ntotal pull: 49 across 4 lights; after every fix: 19"
}
Reply:
{
"task": "light-plan",
"verdict": "beacon",
"summary": "Item 1, the porch lamp by the front door, pulls hardest on moths, making your home attractive to them. The pull across every light adds up to 49, dropping to 19 once changes are made.",
"lights": [
{
"item": 1,
"fix": "aim-down",
"why": "Its spread, bare bulb or points up, is the highest-scoring setting here.",
"first_step": "Switch off the porch lamp by the front door, let it cool, then fit a shade that aims it down for finding keys."
},
{
"item": 2,
"fix": "curtain",
"why": "Its spread, no curtain, scores higher than its other settings.",
"first_step": "Close a curtain or blind on the kitchen window over the sink so the room stays lit inside while you wash up."
},
{
"item": 3,
"fix": "warm-bulb",
"why": "Its colour, cool or bluish white, outscores its other settings.",
"first_step": "Switch off the floodlight on the shed, let it cool, then swap in a warm or amber bulb for putting the bins out."
},
{
"item": 4,
"fix": "keep",
"why": "Its pull is already low enough to need no change.",
"first_step": "Leave the landing window's thin or half-open curtain as it is, light off at bedtime, for going up to bed."
}
],
"still_useful": "Aimed down, the porch lamp by the front door still lights the door brightly enough for finding keys, just without spilling light up and out.",
"closing": "Whenever you're ready, the first small changes below can calm your home's strong pull on moths."
}
First-floor flat with a balcony - total pull 16, 8 after the fixes: glow
Input:
{
"task": "light-plan",
"place": "first-floor flat with a balcony",
"lights": "item 1 | outdoor light | name: balcony string lights | colour: warm or amber | spread: shines sideways | on: all night | for: sitting out | pull: 6 (1 x 2 x 3)\nitem 2 | room seen through a window | name: living room window | colour: plain white | spread: thin or half-open curtain | on: evenings, off at bedtime | for: reading | pull: 8 (2 x 2 x 2)\nitem 3 | room seen through a window | name: bedroom window | colour: warm or amber | spread: curtain or blind closed | on: evenings, off at bedtime | for: getting ready for bed | pull: 2 (1 x 1 x 2)",
"facts": "light 1: pull 6, some | fix: timer | reason: its hours setting scores highest | after the fix: 2\nlight 2: pull 8, some | fix: curtain | reason: its spread setting scores highest | after the fix: 4\nlight 3: pull 2, faint | fix: keep | reason: pull 4 or less | after the fix: 2\nverdict: glow | reason: a light pulls 8 or more\nbrightest pull: item 2 (pull 8)\ntotal pull: 16 across 3 lights; after every fix: 8"
}
Reply:
{
"task": "light-plan",
"verdict": "glow",
"summary": "Item 2, the living room window, pulls hardest. Overall the flat gives off a noticeable pull after dark, adding up to 16 now and 8 once every fix is done.",
"lights": [
{
"item": 1,
"fix": "timer",
"why": "Its hours score highest - it stays on all night, more than its sideways spread or warm colour.",
"first_step": "Add a plug-in timer or motion sensor to the balcony string lights, so they switch off once you've finished sitting out."
},
{
"item": 2,
"fix": "curtain",
"why": "Its spread scores highest - the curtain is only thin or half-open, so light shows through it.",
"first_step": "Close the living room window's thin or half-open curtain once it's dark, so you can keep reading with the room lit inside."
},
{
"item": 3,
"fix": "keep",
"why": "Its pull is already low - just 2 - so nothing about colour, spread or hours needs to change yet.",
"first_step": "Keep closing the bedroom window's curtain or blind each evening, just as you already do, to help with getting ready for bed."
}
],
"still_useful": "With the curtain closed once it's dark, the living room window still gives you light for reading, just kept inside instead of spilling out.",
"closing": "Whenever you're ready, closing that curtain could be a simple first step toward quieter light after dark."
}
Terraced cottage - total pull 11, 7 after the fixes: lights low
Input:
{
"task": "light-plan",
"place": "terraced cottage",
"lights": "item 1 | outdoor light | name: lamp over the front door | colour: warm or amber | spread: points down or shaded | on: only when a sensor sees movement | for: unlocking the door | pull: 1 (1 x 1 x 1)\nitem 2 | room seen through a window | name: bathroom window | colour: warm or amber | spread: thin or half-open curtain | on: evenings, off at bedtime | for: evening bath | pull: 4 (1 x 2 x 2)\nitem 3 | outdoor light | name: solar lights along the path | colour: plain white | spread: points down or shaded | on: all night | for: walking to the gate | pull: 6 (2 x 1 x 3)",
"facts": "light 1: pull 1, faint | fix: keep | reason: pull 4 or less | after the fix: 1\nlight 2: pull 4, faint | fix: keep | reason: pull 4 or less | after the fix: 4\nlight 3: pull 6, some | fix: timer | reason: its hours setting scores highest | after the fix: 2\nverdict: low | reason: every light pulls under 8\nbrightest pull: item 3 (pull 6)\ntotal pull: 11 across 3 lights; after every fix: 7"
}
Reply:
{
"task": "light-plan",
"verdict": "low",
"summary": "Item 3, the solar lights along the path, pulls hardest at 6. Your terraced cottage's lights all stay under 8, keeping the whole place low. Together they pull 11 now, 7 after every fix.",
"lights": [
{
"item": 1,
"fix": "keep",
"why": "Its pull is already low, at 1, so none of its settings need changing.",
"first_step": "Keep an eye on the sensor so it still responds well for unlocking the door when you arrive."
},
{
"item": 2,
"fix": "keep",
"why": "Its pull is already low, at 4, so no setting needs changing.",
"first_step": "Keep the curtain at the same thin or half-open gap each evening for your evening bath."
},
{
"item": 3,
"fix": "timer",
"why": "Being on all night scores highest among its settings, so shortening its hours is the fix.",
"first_step": "Switch the lights off, let them cool, then fit a motion sensor so they still light the path for walking to the gate."
}
],
"still_useful": "Even with shorter hours, the solar lights along the path still switch on with movement, so walking to the gate stays lit and safe.",
"closing": "Your terraced cottage already sits low for moths, so try these small steps whenever feels right for you."
}
Build the input yourself
Load the engine in node (global.window = global; require("./glowkit.js")), then:
const K = GlowKit;
const built = K.buildInput(K.form({ place: "corner house with a front garden",
lights: [{ place: "outdoor", name: "porch lamp by the front door", colour: "cool", spread: "high", hours: "night", need: "finding keys" },
{ place: "window", name: "kitchen window over the sink", colour: "neutral", spread: "high", hours: "evening", need: "washing up" }] }));
built.input.facts; // the page's lines: each light's pull and fix, the verdict, the totals
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// built.facts.verdict // the verdict the page's rule gives
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, "my house") // the plan 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://lights-low.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://lights-low.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"]
// lights-low.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://lights-low.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://lights-low.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
}
// LightsLow.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 LightsLow {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://lights-low.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://lights-low.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://lights-low.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://lights-low.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":"lights-low"}' | 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": "lights-low"})["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: "lights-low" })).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": "lights-low"}, 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", "lights-low"), 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: "lights-low" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "lights-low"])["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 = "lights-low" }))!["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": "light-plan", "place": "corner house with a front garden", "lights": "item 1 | outdoor light | name: porch lamp by the front door | ...", "facts": "light 1: pull 27, strong | fix: aim-down | ..."}
# - 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": "light-plan", "place": "corner house with a front garden", "lights": "item 1 | outdoor light | name: porch lamp by the front door | ...", "facts": "light 1: pull 27, strong | fix: aim-down | ..."}
# - 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: "light-plan", place, lights, 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 lights-low:<task>:<inputHash>:a<attempt> (the one task is light-plan), 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="lights-low:light-plan:$(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 = "lights-low:" + 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("verdict"))
import { createHash } from "node:crypto";
const key = `lights-low:${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.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "lights-low:" + 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 = "lights-low:" + 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 = "lights-low:#{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["verdict"]].inspect
$key = "lights-low:" . $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["verdict"]), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"lights-low:{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: lights-low:light-plan:$(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": "light-plan", "place": "corner house with a front garden", "lights": "item 1 | outdoor light | name: porch lamp by the front door | ...", "facts": "light 1: pull 27, strong | fix: aim-down | ..."}
# - 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": "lights-low:light-plan:" + 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": `lights-low:light-plan:${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", "lights-low:light-plan:"+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", "lights-low:light-plan:" + 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" => "lights-low:light-plan:#{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: lights-low:light-plan:" . 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", $"lights-low:light-plan:{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);