Drive Type Case from your own code
Both metered lanes are available over HTTP. pair takes a brief for one piece - what it is, where it is read (screen, print or wall), the mood, the real headline, some body text and what to avoid - and returns pairs (three pairings from the case: safe, character and wild card, each with faces, weights, sizes, a reason and a risk to watch) or ask-first (questions, when the brief names only a format). check takes the same brief plus the pair you set and returns keep, tweak or swap, with proofreader's notes and a fixed pair. A natural use is a site builder that suggests type for a new page and checks the sizes a user picks.
The division of labour first: the page owns the case, the size floors and the thin-brief test; the model chooses and explains; the page re-checks the reply. The case, the clipping and the checks come from typekit.js, which runs in node as well as in the browser. TypeKit.buildInput computes body_floor for the venue and thin_brief for the pair lane. TypeKit.check holds each reply to the case: every face is one of the thirteen, every weight is one that face has, every body face is a text face, every body size clears the floor, every heading is at least 1.25 times its body, the verdict matches what the page can measure, and no capitalised name or number turns up that the brief and the case do not hold.
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":"type-case"}), 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, piece or venue 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: both lanes are metered and need 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 and the verdicts
Every run sends task: pair or check; a missing or unknown task is treated as pair. The case holds thirteen faces; six are display only (Young Serif, Instrument Serif, Abril Fatface, Syne, Shrikhand, Caveat) and may set a heading but never the body. Each lane decides its verdict by ordered, non-overlapping rules - the first that applies wins:
| Lane | Order | Verdict | When |
|---|---|---|---|
| pair | 1 | ask-first | thin_brief is "yes" (the page sets it when the piece is three words or fewer and there is no mood and no body text). No pairs; asks holds two or three questions. |
| pair | 2 | pairs | Otherwise: exactly three pairs - safe, character, wild card - with three different heading faces. |
| check | 1 | swap | The body face is a display face, or a face plainly works against a word in the mood or avoid fields. The fix changes the face at fault. |
| check | 2 | tweak | Otherwise, if the body size is under body_floor or the heading is less than 1.25 times the body. The fix keeps both faces and changes sizes. |
| check | 3 | keep | None of the above. No issues; every fix field empty. |
The declared input schema carries scalars only, and every value is a string (TypeKit.toWire does this).
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | pair or check. Required. |
piece | string | What is being made, up to about 120 characters. Required. |
venue | string | screen, print or wall. Required. |
mood, avoid | string | The feel wanted and anything not wanted; may be empty. |
headline, sample | string | The real headline (up to about 90 characters) and some body text (up to about 700); sample may be empty. |
body_floor | string | The smallest comfortable body size for the venue, in px: "16" screen, "12" print, "24" wall - rules of thumb. |
thin_brief | string | pair only: "yes" or "no". |
heading_font, body_font, heading_weight, body_weight | string | check only: face ids from the case (for example young-serif, literata) and weights that face has. |
heading_px, body_px | string | check only: sizes in CSS px. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object. Both lanes carry task, verdict, title and summary. pair adds pairs ([{id, label, heading, heading_weight, body, body_weight, heading_px, body_px, why, watch}]; ids P1..; label is safe, character or wild-card) and asks. check adds issues ([{rule, note}]; rule is display-body, mood, too-small or flat-scale), fix ({heading, heading_weight, body, body_weight, heading_px, body_px}, all empty on keep) and works. Parse with TypeKit.parseReply, then normalize(reply, task), then check(reply, form), which returns the page's notes; TypeKit.cssOf(pair) turns any pair into CSS.
Worked examples
Replies produced against the app's prompt for the six sample runs (made-up briefs; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict of both lanes.
pair - a weekend bakery menu card, printed: pairs
Input:
{
"task": "pair",
"piece": "Menu card for a weekend bakery stall",
"venue": "print",
"mood": "warm, handmade, a little old-fashioned",
"headline": "Saturday Bakes",
"sample": "Cardamom buns, rye sourdough and a plum galette. Everything is baked the night before and sold until it runs out.",
"avoid": "nothing that looks like a supermarket",
"body_floor": "12",
"thin_brief": "no"
}
Reply:
{
"task": "pair",
"verdict": "pairs",
"title": "A Warm, Handmade Weekend Bakery Menu",
"summary": "All three pairs stay warm and handmade, keep the body text at a comfortable size for a card held in the hand, and avoid anything that reads as a supermarket. The safe pair leans on an old-fashioned serif heading over a bookish body text, the character pair swaps in a handwritten heading over a characterful grotesque for a more personal touch, and the wild-card pair goes bigger and more playful with a retro display heading set against a plain, neutral body.",
"pairs": [
{
"id": "P1",
"label": "safe",
"heading": "young-serif",
"heading_weight": "400",
"body": "literata",
"body_weight": "400",
"heading_px": "30",
"body_px": "13",
"why": "Young Serif is built to feel warm and a little old-fashioned, which matches the mood directly, and Literata stays sturdy and bookish at small sizes so the list of bakes stays easy to read on a card held in the hand, never drifting toward a supermarket look.",
"watch": "Young Serif only comes in one weight, so there is no bolder cut to lean on if part of the headline needs more punch."
},
{
"id": "P2",
"label": "character",
"heading": "caveat",
"heading_weight": "700",
"body": "bricolage",
"body_weight": "400",
"heading_px": "38",
"body_px": "13",
"why": "Caveat brings a handwritten, personal touch that matches the handmade mood, and Bricolage Grotesque is a quirky but readable grotesque that keeps the longer lines about the bakes legible while still feeling characterful rather than generic.",
"watch": "Caveat suits short bits like labels, so keep it to the headline and do not stretch it across the longer lines about the bakes."
},
"... (3 in all)"
],
"asks": []
}
pair - a tool library landing page, on screen: pairs
Input:
{
"task": "pair",
"piece": "Landing page for a neighbourhood tool library",
"venue": "screen",
"mood": "practical, friendly, trustworthy",
"headline": "Borrow the drill. Skip the store.",
"sample": "Members borrow ladders, saws and sanders for a week at a time. Join with a library card and a short safety talk.",
"avoid": "nothing that looks like a bank",
"body_floor": "16",
"thin_brief": "no"
}
Reply:
{
"task": "pair",
"verdict": "pairs",
"title": "A Practical, Trustworthy Library Landing Page",
"summary": "All three pairings pick up the brief's own words practical, friendly and trustworthy, keep the body face plain and easy to read on a screen, and stay well away from anything that could look like a bank. The safe pairing leans on a sturdy, workmanlike heading over a quiet, neutral body; the character pairing adds a characterful, ink-trapped heading over a bookish body that echoes the word library; and the wild-card pairing brings in a wide, arty heading over a plain-spoken body for more energy without losing trust.",
"pairs": [
{
"id": "P1",
"label": "safe",
"heading": "zilla-slab",
"heading_weight": "700",
"body": "hanken",
"body_weight": "400",
"heading_px": "28",
"body_px": "16",
"why": "This stays close to the brief's own feel: Zilla Slab's sturdy, workmanlike character suits a tool library, and Hanken Grotesk is a quiet, neutral workhorse that keeps the body plain and trustworthy on a screen.",
"watch": "Watch that the slab's bold weight does not get set any heavier or larger than this, since that can tip the sturdy feel into something heavy rather than friendly."
},
{
"id": "P2",
"label": "character",
"heading": "bricolage",
"heading_weight": "700",
"body": "literata",
"body_weight": "400",
"heading_px": "34",
"body_px": "17",
"why": "This brings in more personality while staying friendly: Bricolage Grotesque's ink-trapped character gives the heading some warmth, and Literata's bookish, sturdy-at-small-sizes feel nods to the brief's own library without losing trust.",
"watch": "Watch that Bricolage Grotesque's ink traps do not get muddy if the heading is set smaller than this, since the quirks need room to stay legible."
},
"... (3 in all)"
],
"asks": []
}
pair - "A poster" with no mood and no body text: ask-first
Input:
{
"task": "pair",
"piece": "A poster",
"venue": "wall",
"mood": "",
"headline": "Open Day",
"sample": "",
"avoid": "",
"body_floor": "24",
"thin_brief": "yes"
}
Reply:
{
"task": "pair",
"verdict": "ask-first",
"title": "A Poster for Open Day",
"summary": "The brief names the piece and its headline but leaves the feel and anything to avoid unsaid, so pairing now would mean guessing. A few short answers will let the heading and body faces truly suit this poster before it goes up on the wall.",
"pairs": [],
"asks": [
"Who will be reading this poster?",
"What feeling should it have?",
"Is there a look the poster should avoid?"
]
}
check - Bricolage Grotesque 700 at 44 px over Atkinson Hyperlegible 400 at 18 px: keep
Input:
{
"task": "check",
"piece": "Landing page for a neighbourhood tool library",
"venue": "screen",
"mood": "practical, friendly, trustworthy",
"headline": "Borrow the drill. Skip the store.",
"sample": "Members borrow ladders, saws and sanders for a week at a time. Join with a library card and a short safety talk.",
"avoid": "nothing that looks like a bank",
"body_floor": "16",
"heading_font": "bricolage",
"heading_weight": "700",
"body_font": "atkinson",
"body_weight": "400",
"heading_px": "44",
"body_px": "18"
}
Reply:
{
"task": "check",
"verdict": "keep",
"title": "Bricolage Grotesque Over Atkinson Hyperlegible",
"summary": "This pair can stay as set: the heading face is characterful but readable, and the body face is plain-spoken with every letter distinct, matching the practical, friendly and trustworthy feel asked for, with nothing that looks like a bank. The body size already sits above the smallest comfortable body size, and the heading is comfortably larger than the safe minimum, so the scale reads clearly on screen.",
"issues": [],
"fix": {
"heading": "",
"heading_weight": "",
"body": "",
"body_weight": "",
"heading_px": "",
"body_px": ""
},
"works": "The characterful heading stays readable, and the plain-spoken body face keeps every word of the sample clear and trustworthy."
}
check - Young Serif at 13 px over Literata at 11 px on a printed menu: tweak
Input:
{
"task": "check",
"piece": "Menu card for a weekend bakery stall",
"venue": "print",
"mood": "warm, handmade, a little old-fashioned",
"headline": "Saturday Bakes",
"sample": "Cardamom buns, rye sourdough and a plum galette. Everything is baked the night before and sold until it runs out.",
"avoid": "nothing that looks like a supermarket",
"body_floor": "12",
"heading_font": "young-serif",
"heading_weight": "400",
"body_font": "literata",
"body_weight": "400",
"heading_px": "13",
"body_px": "11"
}
Reply:
{
"task": "check",
"verdict": "tweak",
"title": "Young Serif Over Literata",
"summary": "This is a tweak: the faces already suit the warm, old-fashioned mood, but the sizes do not yet hold up in print. The body size sits under the smallest comfortable size for this piece, and the heading does not clear the safe scale over the body, so the two text sizes read too close together.",
"issues": [
{
"rule": "too-small",
"note": "The body size of 11 falls below the smallest comfortable body size of 12 for this piece."
},
{
"rule": "flat-scale",
"note": "The heading size of 13 over the body size of 11 comes out under the 1.25 minimum, so heading and body sit too close in scale."
}
],
"fix": {
"heading": "young-serif",
"heading_weight": "400",
"body": "literata",
"body_weight": "400",
"heading_px": "15",
"body_px": "12"
},
"works": "Young Serif's warm, old-fashioned feel over Literata's sturdy small-size reading keeps the bakery mood intact, and the fix holds both faces exactly as set."
}
check - Abril Fatface over Caveat as body text: swap
Input:
{
"task": "check",
"piece": "Landing page for a neighbourhood tool library",
"venue": "screen",
"mood": "practical, friendly, trustworthy",
"headline": "Borrow the drill. Skip the store.",
"sample": "Members borrow ladders, saws and sanders for a week at a time. Join with a library card and a short safety talk.",
"avoid": "nothing that looks like a bank",
"body_floor": "16",
"heading_font": "abril-fatface",
"heading_weight": "400",
"body_font": "caveat",
"body_weight": "400",
"heading_px": "48",
"body_px": "18"
}
Reply:
{
"task": "check",
"verdict": "swap",
"title": "Abril Fatface Over Caveat",
"summary": "This pairing calls for a swap: the body is set in a handwritten display face, and a face like that is never meant to carry running text such as the sample about borrowing ladders, saws and sanders. Setting the body in a steady text face instead keeps the page feeling practical and trustworthy, while the bold heading still carries the short headline with plenty of presence for a screen read.",
"issues": [
{
"rule": "display-body",
"note": "The body face is a handwritten display face marked for display only, not for running text, so it will not hold up as body copy for the sample's full sentences."
}
],
"fix": {
"heading": "abril-fatface",
"heading_weight": "400",
"body": "hanken",
"body_weight": "400",
"heading_px": "48",
"body_px": "18"
},
"works": "The heading's bold, high-contrast scale already gives the short headline strong presence, and its size clears the smallest comfortable body size by a wide margin, so the fix keeps the heading exactly as set."
}
Build the input yourself
Load the script in node (global.window = global; require("./typekit.js")), then:
const K = TypeKit;
const form = K.form({ piece: "Menu card for a weekend bakery stall", venue: "print", mood: "warm, handmade",
headline: "Saturday Bakes", sample: "Cardamom buns and rye sourdough.", avoid: "",
heading_font: "young-serif", heading_weight: "400", body_font: "literata", body_weight: "400", heading_px: "30", body_px: "13" });
console.log(K.flags("check", form), K.leanCheck(form), K.stick(form).ratio); // the proof marks, the page's lean, the scale
const wire = K.toWire(K.buildInput("pair", form).input); // POST this to /estimate and /run ("check" adds the stick)
// then: const reply = K.normalize(K.parseReply(job.output.output), "pair");
// const { notes } = K.check(reply, form); // the page's re-check of the reply
// console.log(K.cssOf(reply.pairs[0])); // CSS for the safe pair
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://type-case.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://type-case.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"]
// type-case.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://type-case.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://type-case.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
}
// TypeCase.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 TypeCase {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://type-case.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://type-case.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://type-case.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://type-case.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":"type-case"}' | 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": "type-case"})["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: "type-case" })).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": "type-case"}, 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", "type-case"), 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: "type-case" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "type-case"])["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 = "type-case" }))!["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": "pair", "piece": "Menu card for a weekend bakery stall", "venue": "print", "mood": "warm, handmade, a little old-fashioned", "headline": "Saturday Bakes", "sample": "Cardamom buns, rye sourdough and a plum ...", "body_floor": "12", "thin_brief": "no"}
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": "pair", "piece": "Menu card for a weekend bakery stall", "venue": "print", "mood": "warm, handmade, a little old-fashioned", "headline": "Saturday Bakes", "sample": "Cardamom buns, rye sourdough and a plum ...", "body_floor": "12", "thin_brief": "no"}
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: "pair", piece, venue, mood, headline, sample, body_floor, thin_brief, ... (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 type-case:<task>:<inputHash>:a<attempt> (the lanes are pair and 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="type-case:pair:$(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 = "type-case:" + 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 = `type-case:${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 := "type-case:" + 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 = "type-case:" + 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 = "type-case:#{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 = "type-case:" . $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 = $"type-case:{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: type-case:pair:$(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": "pair", "piece": "Menu card for a weekend bakery stall", "venue": "print", "mood": "warm, handmade, a little old-fashioned", "headline": "Saturday Bakes", "sample": "Cardamom buns, rye sourdough and a plum ...", "body_floor": "12", "thin_brief": "no"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "type-case:pair:" + 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": `type-case:pair:${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", "type-case:pair:"+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", "type-case:pair:" + 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" => "type-case:pair:#{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: type-case:pair:" . 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", $"type-case:pair:{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);