Drive Flatpack Steps from your own code
The one metered build is available over HTTP: give it what the sheet is for, who will follow it, a step limit and the notes as written, and it returns a verdict, a parts list lettered A, B, C, numbered steps that each do one thing (with the letters they use, a picture tag and an optional warning printed before the action), the questions the notes leave open, and each changed line with the rule it broke. A natural use is a house-manual tool or a holiday-rental welcome pack that wants instructions a guest can follow without a phone call.
The division of labour first: the page owns the check; the model builds the sheet; the page re-checks the reply. Splitting the notes into candidate steps, the rule tags (S1 one action, S2 verb first, S3 20 words, S4 no vague words, and "never says"), the number list and the verdict (by the same ordered rules the model gets) come from flatkit.js, which runs in node as well as in the browser. Its check function holds each reply to the notes: the step limit, 20 words per step, verb-first openers, part letters that exist, every number from the notes on the sheet and no number they never gave, questions only for gaps, changes that quote real note lines, no internal field names in prose.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
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": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"flatpack-steps"}), 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 such as task or notes 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: the build is metered and needs a personal token. |
| 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
Every run sends task: sheet - there is one lane. The verdict is decided by four ordered, non-overlapping rules; the first that matches wins:
| Order | Verdict | When |
|---|---|---|
| 1 | gaps | A step cannot be done because the notes treat a needed fact as known and never give it ("the usual spot", "like last time", "the code" with no code). The sheet carries [ask: ...] placeholders and a question per missing fact. |
| 2 | rebuild | Fewer than three separate steps, a step that needs something only done later, or more than half of the candidate steps breaking a sentence rule. |
| 3 | tighten | At least one candidate step breaks a sentence rule (S1-S4); the rest keep their wording. |
| 4 | ready | Everything else: at most two small changes. |
The declared input schema carries scalars only, so array-valued fields travel JSON-encoded as strings (FlatKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | sheet. |
job | string | What the sheet is for, up to 100 characters (Bin night). |
for_whom | string | One of house-sitter, babysitter, guest, parent or grandparent, roommate, coworker, kid, future me, or empty. |
steps_max | string | 6, 10 or 14; anything else becomes 10. |
notes | string | The notes as written. Past 4,000 characters whole lines are cut from the middle with a marker. After an ask-first sheet the page appends an Answers: block, one question: answer per line; the model treats each answer as part of the notes. |
page_steps | string (JSON) | Array of strings, one per candidate step: "3. [S1 two actions] Fill the green can at the kitchen tap and then water the herbs on the balcony.". |
page_numbers | string (JSON) | Array of every number in the notes (["3"]). |
page_lean | string | The page's verdict from its word lists, by the rules above. |
page_flags | string (JSON) | Array of the page's warnings (a thing never said, more steps than the limit). |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, parts ([{id, name, qty}], qty empty unless the notes give a count), steps ([{action, parts: [ids], icon, warning}], icon one of do, look, wait, warn, two, lock, water, heat, power, call, bin, door), questions ([{ask, step}], only for gaps) and changes ([{was, now, why}], 0-2 for ready). Parse it with FlatKit.parseReply, then normalize(reply), then check(reply, form), which returns {notes}.
Worked examples
Replies produced against the app's prompt for the five samples (made-up homes with made-up notes; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict.
plants for a house-sitter - tighten
Input:
{
"task": "sheet",
"job": "Water the plants while we're away",
"for_whom": "house-sitter",
"steps_max": "10",
"notes": "Water the big fern in the living room every 3 days.\nGive the cactus on the windowsill a bit of water once a week.\nFill the green can at the ... (271 characters)",
"page_steps": "[\"1. Water the big fern in the living room every 3 days.\",\"2. [S4 vague: a bit] Give the cactus on the windowsill a bit of water once a week.\",\"3. [S1 two actions] Fill the green c ... (333 characters)",
"page_numbers": "[\"3\"]",
"page_lean": "tighten",
"page_flags": "[]"
}
Reply:
{
"task": "sheet",
"verdict": "tighten",
"title": "Watering the Plants While Away",
"summary": "This is a tighten: the cactus step used a vague amount and the can step held two actions, so those are the only two lines that needed fixing; the rest of your notes already read as clear steps. The cactus step now asks for water without guessing an amount, and filling the can is now its own step before watering the herbs. Everything else is copied from your notes as written.",
"parts": [
{
"id": "A",
"name": "the big fern in the living room",
"qty": ""
},
{
"id": "B",
"name": "the cactus on the windowsill",
"qty": ""
},
{
"id": "C",
"name": "the green can",
"qty": ""
},
"... (6 in all)"
],
"steps": [
{
"action": "Water the big fern in the living room every 3 days.",
"parts": [
"A"
],
"icon": "water",
"warning": ""
},
{
"action": "Give the cactus on the windowsill water once a week.",
"parts": [
"B"
],
"icon": "water",
"warning": ""
},
{
"action": "Fill the green can at the kitchen tap.",
"parts": [
"C"
],
"icon": "water",
"warning": ""
},
"... (6 in all)"
],
"questions": [],
"changes": [
{
"was": "Give the cactus on the windowsill a bit of water once a week.",
"now": "Give the cactus on the windowsill water once a week.",
"why": "Vague amount \"a bit\" with no amount given in the notes - removed (S4)."
},
{
"was": "Fill the green can at the kitchen tap and then water the herbs on the balcony.",
"now": "Fill the green can at the kitchen tap. / Water the herbs on the balcony.",
"why": "Two actions in one step - split into two steps (S1)."
}
]
}
the new TV for a parent, one paragraph - rebuild
Input:
{
"task": "sheet",
"job": "Watch the cooking channel on the new TV",
"for_whom": "parent or grandparent",
"steps_max": "6",
"notes": "You need to use the black remote, not the silver one, the silver one is for the soundbar. Press the round button at the top and wait a bit f ... (331 characters)",
"page_steps": "[\"1. [S2 hedged opener] You need to use the black remote, not the silver one, the silver one is for the soundbar.\",\"2. [S1 two actions, S4 vague: a bit] Press the round button at t ... (442 characters)",
"page_numbers": "[]",
"page_lean": "rebuild",
"page_flags": "[]"
}
Reply:
{
"task": "sheet",
"verdict": "rebuild",
"title": "Watch the Cooking Channel",
"summary": "This is a rebuild: three of the four candidate steps broke a sentence rule (a hedged opener, two actions bundled into one sentence, a vague time word, and an overlong sentence), so the sheet was written fresh from your notes' facts. Pressing the button and waiting for the screen are now separate steps, and so are pressing Home, going to the app, and finding the channel. The remote mix-up became a warning on step 1 instead of its own step, which kept the sheet at 6 steps without dropping any fact.",
"parts": [
{
"id": "A",
"name": "black remote",
"qty": ""
},
{
"id": "B",
"name": "soundbar",
"qty": ""
}
],
"steps": [
{
"action": "Press the round button at the top.",
"parts": [
"A"
],
"icon": "power",
"warning": "Do not use the silver remote - it is for the soundbar."
},
{
"action": "Wait for the screen to come on.",
"parts": [],
"icon": "wait",
"warning": ""
},
{
"action": "Press Home on the black remote.",
"parts": [
"A"
],
"icon": "do",
"warning": ""
},
"... (6 in all)"
],
"questions": [],
"changes": [
{
"was": "You need to use the black remote, not the silver one, the silver one is for the soundbar.",
"now": "Do not use the silver remote - it is for the soundbar.",
"why": "Hedged opener (S2) - turned into step 1's warning instead of a separate step."
},
{
"was": "Press the round button at the top and wait a bit for the screen to come on.",
"now": "Press the round button at the top. / Wait for the screen to come on.",
"why": "Two actions joined with 'and' (S1) and a vague time word (S4) - split into two steps and dropped the vague word since no amount was given."
},
"... (4 in all)"
]
}
keys and heating for guests - gaps (ask first)
Input:
{
"task": "sheet",
"job": "Heating and doors for our guests",
"for_whom": "guest",
"steps_max": "10",
"notes": "Front door key is in the usual spot.\nTurn the heating on at the panel in the hall.\nSet it to 20.\nPut in the code to turn the alarm off when ... (181 characters)",
"page_steps": "[\"1. [G never says: \\\"the usual spot\\\"] Front door key is in the usual spot.\",\"2. Turn the heating on at the panel in the hall.\",\"3. Set it to 20.\",\"4. [G never says: \\\"the code\\\"] ... (272 characters)",
"page_numbers": "[\"20\"]",
"page_lean": "gaps",
"page_flags": "[\"Step 1 leans on \\\"the usual spot\\\" but your notes never say it.\",\"Step 4 leans on \\\"the code\\\" but your notes never say it.\"]"
}
Reply:
{
"task": "sheet",
"verdict": "gaps",
"title": "Heating and Doors for Guests",
"summary": "This is a gaps sheet: the notes treat the front door key's spot and the alarm code as already known but never state them, so two steps are missing a fact the reader cannot work out alone. I built the full five-step sequence anyway, adding a placeholder and a question for each missing fact, moved the alarm step's \"when you come in\" condition to the front of its sentence, and named the heating instead of the pronoun \"it\" in the temperature step. The heating-on and back-door steps are otherwise copied from the notes as written.",
"parts": [
{
"id": "A",
"name": "the front door key",
"qty": ""
},
{
"id": "B",
"name": "the panel in the hall",
"qty": ""
},
{
"id": "C",
"name": "the alarm",
"qty": ""
},
"... (4 in all)"
],
"steps": [
{
"action": "Get the front door key [ask: where the key is].",
"parts": [
"A"
],
"icon": "lock",
"warning": ""
},
{
"action": "Turn the heating on at the panel in the hall.",
"parts": [
"B"
],
"icon": "heat",
"warning": ""
},
{
"action": "Set the heating to 20.",
"parts": [
"B"
],
"icon": "heat",
"warning": ""
},
"... (5 in all)"
],
"questions": [
{
"ask": "Where is the front door key kept?",
"step": 1
},
{
"ask": "What is the alarm code?",
"step": 4
}
],
"changes": [
{
"was": "Front door key is in the usual spot.",
"now": "Get the front door key [ask: where the key is].",
"why": "The notes never say where the usual spot is - added a placeholder and a question (gap)."
},
{
"was": "Set it to 20.",
"now": "Set the heating to 20.",
"why": "Named the heating instead of the pronoun \"it\" so one thing keeps one name (S5)."
},
"... (3 in all)"
]
}
bin night for a roommate - ready
Input:
{
"task": "sheet",
"job": "Bin night",
"for_whom": "roommate",
"steps_max": "6",
"notes": "1. Take the green bin to the curb on Sunday night.\n2. Put the glass box next to the green bin.\n3. Bring both back in on Monday after 10am.\n4 ... (176 characters)",
"page_steps": "[\"1. Take the green bin to the curb on Sunday night.\",\"2. Put the glass box next to the green bin.\",\"3. Bring both back in on Monday after 10am.\",\"4. Rinse the glass box with the h ... (186 characters)",
"page_numbers": "[\"10\"]",
"page_lean": "ready",
"page_flags": "[]"
}
Reply:
{
"task": "sheet",
"verdict": "ready",
"title": "Bin Night Steps",
"summary": "This sheet is ready: the notes already give four clear, one-action steps in the right order with no missing facts. Nothing needed to change beyond listing the green bin, the glass box and the hose as parts; the page's own verdict of ready agrees.",
"parts": [
{
"id": "A",
"name": "the green bin",
"qty": ""
},
{
"id": "B",
"name": "the glass box",
"qty": ""
},
{
"id": "C",
"name": "the hose",
"qty": ""
}
],
"steps": [
{
"action": "Take the green bin to the curb on Sunday night.",
"parts": [
"A"
],
"icon": "bin",
"warning": ""
},
{
"action": "Put the glass box next to the green bin.",
"parts": [
"A",
"B"
],
"icon": "bin",
"warning": ""
},
{
"action": "Bring both back in on Monday after 10am.",
"parts": [
"A",
"B"
],
"icon": "bin",
"warning": ""
},
"... (4 in all)"
],
"questions": [],
"changes": []
}
a router reset for future me, one run-on line - rebuild
Input:
{
"task": "sheet",
"job": "Reset the wifi router",
"for_whom": "future me",
"steps_max": "6",
"notes": "Unplug the router, wait 30 seconds and plug it back in, then hold the reset pin for 10 seconds if the lights stay orange and wait for the gr ... (150 characters)",
"page_steps": "[\"1. [S1 two actions, S3 long, 30 words] Unplug the router, wait 30 seconds and plug it back in, then hold the reset pin for 10 seconds if the lights stay orange and wait for the g ... (193 characters)",
"page_numbers": "[\"30\",\"10\"]",
"page_lean": "rebuild",
"page_flags": "[\"Fewer than three steps - say each thing to do on its own line.\"]"
}
Reply:
{
"task": "sheet",
"verdict": "rebuild",
"title": "Reset the wifi router",
"summary": "This is a rebuild: the notes gave only one run-on step covering five actions, which is fewer than three separate steps and breaks the one-action rule. I split the notes into five single-action steps in their original order, keeping the 30-second wait, the 10-second reset-pin hold only if the lights stay orange, and the final wait for the green light.",
"parts": [
{
"id": "A",
"name": "the router",
"qty": ""
},
{
"id": "B",
"name": "the reset pin",
"qty": ""
}
],
"steps": [
{
"action": "Unplug the router.",
"parts": [
"A"
],
"icon": "power",
"warning": ""
},
{
"action": "Wait 30 seconds.",
"parts": [],
"icon": "wait",
"warning": ""
},
{
"action": "Plug the router back in.",
"parts": [
"A"
],
"icon": "power",
"warning": ""
},
"... (5 in all)"
],
"questions": [],
"changes": [
{
"was": "Unplug the router, wait 30 seconds and plug it back in, then hold the reset pin for 10 seconds if the lights stay orange and wait for the green light.",
"now": "Unplug the router. / Wait 30 seconds. / Plug the router back in. / If the lights stay orange, hold the reset pin for 10 seconds. / Wait for the green light.",
"why": "One run-on sentence with five actions - split into five single-action steps (rebuild)."
}
]
}
Build the input yourself
Load the script in node (global.window = global; require("./flatkit.js")), then:
const K = FlatKit;
const form = { job: "Bin night", for_whom: "roommate", steps_max: "6",
notes: "1. Take the green bin to the curb on Sunday night.\n2. Put the glass box next to the green bin.\n..." };
console.log(K.lean(form), K.scan(form.notes)); // the page's verdict and its candidate steps with rule tags
const wire = K.toWire(K.buildInput(form).input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(reply, form); // the page's re-check of the reply
// K.plainText(reply) // the sheet as plain text
// K.lint("You should water it a bit and then lock up.") // S1, S2 and S4 tags
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://flatpack-steps.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://flatpack-steps.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"]
// flatpack-steps.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://flatpack-steps.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://flatpack-steps.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
}
// FlatpackSteps.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 FlatpackSteps {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://flatpack-steps.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://flatpack-steps.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://flatpack-steps.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://flatpack-steps.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":"flatpack-steps"}' | 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": "flatpack-steps"})["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: "flatpack-steps" })).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": "flatpack-steps"}, 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", "flatpack-steps"), 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: "flatpack-steps" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "flatpack-steps"])["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 = "flatpack-steps" }))!["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": "sheet", "job": "Bin night", "for_whom": "roommate", "steps_max": "6", "notes": "1. Take the green bin to the curb on Sunday night.\n...", "page_steps": "[...]", "page_numbers": "[\"10\"]", "page_lean": "ready", "page_flags": "[]"}
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": "sheet", "job": "Bin night", "for_whom": "roommate", "steps_max": "6", "notes": "1. Take the green bin to the curb on Sunday night.\n...", "page_steps": "[...]", "page_numbers": "[\"10\"]", "page_lean": "ready", "page_flags": "[]"}
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: "sheet", job, for_whom, steps_max, notes, page_steps, ... (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 flatpack-steps:<task>:<inputHash>:a<attempt> (there is one lane, sheet), 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="flatpack-steps:sheet:$(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 = "flatpack-steps:" + 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 read as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `flatpack-steps:${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 := "flatpack-steps:" + 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 = "flatpack-steps:" + 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 = "flatpack-steps:#{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["title"]].inspect
$key = "flatpack-steps:" . $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"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"flatpack-steps:{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: flatpack-steps:sheet:$(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": "sheet", "job": "Bin night", "for_whom": "roommate", "steps_max": "6", "notes": "1. Take the green bin to the curb on Sunday night.\n...", "page_steps": "[...]", "page_numbers": "[\"10\"]", "page_lean": "ready", "page_flags": "[]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "flatpack-steps:sheet:" + 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": `flatpack-steps:sheet:${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", "flatpack-steps:sheet:"+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", "flatpack-steps:sheet:" + 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" => "flatpack-steps:sheet:#{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: flatpack-steps:sheet:" . 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", $"flatpack-steps:sheet:{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);