Drive Ticks and Crosses from your own code
The one metered task, dress, is available over HTTP. It takes a logic-grid puzzle the page has already dealt - the lists, the clues and the first step hints, all in plain words - plus a voice, and returns the clues and hints worded in that voice, with a title and a one-line introduction.
The division of labour first: the page deals and solves the puzzle; the model only words it. The page reads three or four lists (the first items of every list go together, and so on), keeps back any item that looks like an email, phone number, handle or link, deals true clues in a seeded order and keeps only the ones its deduction-only solver needs, so the puzzle has exactly one answer. After the reply it re-checks every worded clue - it must name its own items and no others and keep its kind - and keeps the plain wording for any clue that drifts. Your code can do the same with the engine files (gridkit.js, replykit.js).
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":"ticks-and-crosses"}), 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, voice, setting, lists, clues, clue_count, steps, step_count) 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: wording the clues 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 rules
Every run sends task: dress (the one task is dress); a missing or different one is still answered as dress. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always dress. |
voice | yes | plain, cosy, grand or brisk. |
setting | yes | A few words about where or when (up to 80 characters), or none given. |
lists | yes | One line per list, Name: item, item, item, each list sorted so the answer order never leaks. Three or four lists of three to five items. |
clues | yes | One line per clue, C1 (kind): plain clue. Kinds: goes with, not, either-or, one-and-the-other. Build it with GridKit.buildInput. |
clue_count | yes | How many clues, as a string. |
steps | yes | Up to six step hints, H1 (look at clue N): A goes with B. or H1 (rule: ...): A goes with B. |
step_count | yes | How many steps, as a string. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
The kinds keep their logic: goes with has no negative word, not has exactly one, either-or keeps or and never says both or neither, and one-and-the-other keeps one and the other with the same grouping.
The reply
One JSON object:
{"task": "dress", "voice": "plain", "title": "...", "intro": "...", "clues": [{"id": "C1", "text": "..."}], "hints": [{"id": "H1", "text": "..."}]}
| Field | What it holds |
|---|---|
title | 2-6 words, no item names. |
intro | One or two sentences on what to work out, no item names. |
clues[] | One per clue id, in order: the clue worded in the voice, naming exactly its own items. |
hints[] | One per step id, in order: points at clue N or the rule, then states that step's placement. |
The page reads the reply with ReplyKit.normalize and checks it with ReplyKit.check(reply, puzzle), which lists, under "The page's check", every clue or hint that lost an item, named an extra one, flipped its kind, added a number word or an internal id, and every item name in the title or introduction - and returns the view the page shows, with plain wording wherever a worded clue failed.
Worked examples - every sample
These are the free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all three sizes (three lists of three, four of four, three of five), three voices and all four kinds of clue.
Breakfast mugs
Input:
{
"task": "dress",
"voice": "cosy",
"setting": "Saturday breakfast",
"lists": "Person: Ana, Ben, Cy\nMug: blue mug, chipped mug, striped mug\nDrink: cocoa, coffee, mint tea",
"clues": "C1 (either-or): Mint tea goes with either Ben or Cy.\nC2 (either-or): Ben goes with either coffee or mint tea.\nC3 (not): Chipped mug does not go with mint tea.\nC4 (either-or): Blue mug goes with either Ana or Cy.\nC5 (one-and-the-other): Of Ana and Ben, one goes with blue mug and the other with chipped mug.",
"clue_count": "5",
"steps": "H1 (look at clues 4 and 5): blue mug goes with Ana.\nH2 (look at clue 5): chipped mug goes with Ben.\nH3 (rule: striped mug has only one place left): striped mug goes with Cy.\nH4 (look at clues 1 and 3): mint tea goes with Cy.\nH5 (look at clue 2): coffee goes with Ben.\nH6 (rule: cocoa has only one place left): cocoa goes with Ana.",
"step_count": "6"
}
Reply:
{
"task": "dress",
"voice": "cosy",
"title": "Saturday breakfast mugs and drinks",
"intro": "On Saturday breakfast, work out which person settles with which mug, and which drink comes with each of them.",
"clues": [
{
"id": "C1",
"text": "Mint tea settles with either Ben or Cy."
},
{
"id": "C2",
"text": "Ben settles with either coffee or mint tea."
},
{
"id": "C3",
"text": "Chipped mug does not settle with mint tea."
},
{
"id": "C4",
"text": "Blue mug settles with either Ana or Cy."
},
{
"id": "C5",
"text": "Of Ana and Ben, one settles with blue mug and the other with chipped mug."
}
],
"hints": [
{
"id": "H1",
"text": "Look at clues 4 and 5, and with what you have so far, blue mug settles with Ana."
},
{
"id": "H2",
"text": "Look at clue 5, and with what you have so far, chipped mug settles with Ben."
},
{
"id": "H3",
"text": "Since striped mug has only one place left, and with what you have so far, striped mug settles with Cy."
},
{
"id": "H4",
"text": "Look at clues 1 and 3, and with what you have so far, mint tea settles with Cy."
},
{
"id": "H5",
"text": "Look at clue 2, and with what you have so far, coffee settles with Ben."
},
{
"id": "H6",
"text": "Since cocoa has only one place left, and with what you have so far, cocoa settles with Ana."
}
]
}
A wet Monday at work
Input:
{
"task": "dress",
"voice": "grand",
"setting": "a wet Monday at the office",
"lists": "Colleague: Dana, Eli, Fran, Gus\nLunch: noodles, salad, soup, wrap\nSeat: corner seat, door seat, middle seat, window seat\nUmbrella: blue, green, red, yellow",
"clues": "C1 (either-or): Red goes with either window seat or door seat.\nC2 (one-and-the-other): Of Dana and Eli, one goes with soup and the other with noodles.\nC3 (either-or): Wrap goes with either blue or green.\nC4 (not): Noodles does not go with door seat.\nC5 (not): Salad does not go with door seat.\nC6 (goes with): Wrap goes with middle seat.\nC7 (either-or): Green goes with either soup or noodles.\nC8 (not): Fran does not go with wrap.\nC9 (either-or): Dana goes with either middle seat or window seat.\nC10 (not): Dana does not go with green.",
"clue_count": "10",
"steps": "H1 (look at clues 7 and 10): green goes with Eli.\nH2 (rule: Fran has only one Lunch item left): salad goes with Fran.\nH3 (rule: Gus has only one Lunch item left): wrap goes with Gus.\nH4 (look at clue 3): blue goes with Gus.\nH5 (look at clue 6): middle seat goes with Gus.\nH6 (look at clue 9): window seat goes with Dana.",
"step_count": "6"
}
Reply:
{
"task": "dress",
"voice": "grand",
"title": "A wet Monday at the office",
"intro": "Hear now: on a wet Monday at the office, it falls to you to determine which Colleague is paired with which Lunch, which Seat and which Umbrella.",
"clues": [
{
"id": "C1",
"text": "Red shall belong with either the window seat or the door seat."
},
{
"id": "C2",
"text": "Of Dana and Eli, one shall be joined with soup and the other with noodles."
},
{
"id": "C3",
"text": "Wrap shall belong with either blue or green."
},
{
"id": "C4",
"text": "Noodles shall never go with the door seat."
},
{
"id": "C5",
"text": "Salad shall never go with the door seat."
},
{
"id": "C6",
"text": "Wrap shall belong with the middle seat."
},
{
"id": "C7",
"text": "Green shall belong with either soup or noodles."
},
{
"id": "C8",
"text": "Fran shall never go with wrap."
},
{
"id": "C9",
"text": "Dana shall belong with either the middle seat or the window seat."
},
{
"id": "C10",
"text": "Dana shall never go with green."
}
],
"hints": [
{
"id": "H1",
"text": "Look now to clues 7 and 10: with what you have so far, green goes with Eli."
},
{
"id": "H2",
"text": "Thus Fran has only one Lunch item left, and with what you have so far, salad goes with Fran."
},
{
"id": "H3",
"text": "Thus Gus has only one Lunch item left, and with what you have so far, wrap goes with Gus."
},
{
"id": "H4",
"text": "Look now to clue 3: with what you have so far, blue goes with Gus."
},
{
"id": "H5",
"text": "Look now to clue 6: with what you have so far, the middle seat goes with Gus."
},
{
"id": "H6",
"text": "Look now to clue 9: with what you have so far, the window seat goes with Dana."
}
]
}
The reading corner
Input:
{
"task": "dress",
"voice": "brisk",
"setting": "the reading corner",
"lists": "Reader: Hal, Ida, Jo, Kit, Lou\nBook: atlas, cookbook, diary, novel, poems\nSnack: apple, crisps, grapes, nuts, toast",
"clues": "C1 (either-or): Lou goes with either novel or poems.\nC2 (not): Jo does not go with apple.\nC3 (either-or): Poems goes with either toast or nuts.\nC4 (one-and-the-other): Of diary and novel, one goes with nuts and the other with grapes.\nC5 (either-or): Ida goes with either diary or cookbook.\nC6 (one-and-the-other): Of Ida and Kit, one goes with nuts and the other with crisps.\nC7 (either-or): Novel goes with either nuts or crisps.",
"clue_count": "7",
"steps": "H1 (look at clue 1): poems goes with Lou.\nH2 (look at clue 3): toast goes with Lou.\nH3 (rule: Jo has only one Snack item left): grapes goes with Jo.\nH4 (look at clue 4): diary goes with Jo.\nH5 (look at clue 5): cookbook goes with Ida.\nH6 (rule: novel has only one place left): novel goes with Kit.",
"step_count": "6"
}
Reply:
{
"task": "dress",
"voice": "brisk",
"title": "Reading corner match",
"intro": "In the reading corner, match each Reader to a Book and a Snack.",
"clues": [
{
"id": "C1",
"text": "Lou goes with either novel or poems."
},
{
"id": "C2",
"text": "Jo does not go with apple."
},
{
"id": "C3",
"text": "Poems goes with either toast or nuts."
},
{
"id": "C4",
"text": "Of diary and novel, one goes with nuts and the other with grapes."
},
{
"id": "C5",
"text": "Ida goes with either diary or cookbook."
},
{
"id": "C6",
"text": "Of Ida and Kit, one goes with nuts and the other with crisps."
},
{
"id": "C7",
"text": "Novel goes with either nuts or crisps."
}
],
"hints": [
{
"id": "H1",
"text": "Look at clue 1. With what you have so far, poems goes with Lou."
},
{
"id": "H2",
"text": "Look at clue 3. With what you have so far, toast goes with Lou."
},
{
"id": "H3",
"text": "Jo has only one Snack item left. With what you have so far, grapes goes with Jo."
},
{
"id": "H4",
"text": "Look at clue 4. With what you have so far, diary goes with Jo."
},
{
"id": "H5",
"text": "Look at clue 5. With what you have so far, cookbook goes with Ida."
},
{
"id": "H6",
"text": "Novel has only one place left. With what you have so far, novel goes with Kit."
}
]
}
Build the input yourself
Load the engine in node (global.window = global; require("./gridkit.js"); require("./replykit.js")), then:
const G = GridKit, R = ReplyKit;
const built = G.buildInput(G.form({ lists: "Person: Ana, Ben, Cy\nDrink: cocoa, coffee, tea\nMug: blue, red, white", voice: "plain", seed: "1" })); // built.ok is false, with built.note, until the lists are usable
const wire = G.toWire(built.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, built.puz).notes // the page's re-check of every clue and hint
Form keys: lists (one list per line, items in matching order), setting, voice and seed (the deal number; New clues adds one). The marks on the grid are the page's business and are not sent.
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://ticks-and-crosses.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://ticks-and-crosses.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"]
// ticks-and-crosses.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://ticks-and-crosses.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://ticks-and-crosses.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
}
// TicksAndCrosses.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 TicksAndCrosses {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://ticks-and-crosses.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://ticks-and-crosses.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://ticks-and-crosses.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://ticks-and-crosses.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":"ticks-and-crosses"}' | 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": "ticks-and-crosses"})["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: "ticks-and-crosses" })).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": "ticks-and-crosses"}, 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", "ticks-and-crosses"), 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: "ticks-and-crosses" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "ticks-and-crosses"])["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 = "ticks-and-crosses" }))!["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": "dress", "voice": "plain", "setting": "none given", "lists": "Person: Ana, Ben, Cy\nDrink: cocoa, coffee, tea\nMug: blue, red, white", "clues": "C1 (not): Ana does not go with coffee.\n...", "clue_count": "4", "steps": "H1 (look at clue 1): ...", "step_count": "4"}
# - 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": "dress", "voice": "plain", "setting": "none given", "lists": "Person: Ana, Ben, Cy\nDrink: cocoa, coffee, tea\nMug: blue, red, white", "clues": "C1 (not): Ana does not go with coffee.\n...", "clue_count": "4", "steps": "H1 (look at clue 1): ...", "step_count": "4"}
# - 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: "dress", voice, setting, lists, clues, clue_count, steps, step_count} (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 ticks-and-crosses:<task>:<inputHash>:a<attempt> (the one task is dress), 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="ticks-and-crosses:dress:$(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 = "ticks-and-crosses:" + 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("title"), reply.get("clues"))
import { createHash } from "node:crypto";
const key = `ticks-and-crosses:${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.title, reply.clues);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "ticks-and-crosses:" + 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 = "ticks-and-crosses:" + 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 = "ticks-and-crosses:#{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"], reply["clues"]].inspect
$key = "ticks-and-crosses:" . $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"] ?? ""), " ", count($reply["clues"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"ticks-and-crosses:{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: ticks-and-crosses:dress:$(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": "dress", "voice": "plain", "setting": "none given", "lists": "Person: Ana, Ben, Cy\nDrink: cocoa, coffee, tea\nMug: blue, red, white", "clues": "C1 (not): Ana does not go with coffee.\n...", "clue_count": "4", "steps": "H1 (look at clue 1): ...", "step_count": "4"}
# - 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": "ticks-and-crosses:dress:" + 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": `ticks-and-crosses:dress:${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", "ticks-and-crosses:dress:"+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", "ticks-and-crosses:dress:" + 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" => "ticks-and-crosses:dress:#{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: ticks-and-crosses:dress:" . 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", $"ticks-and-crosses:dress:{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);