Drive After the Masters from your own code
The one metered task, restage, is available over HTTP. It takes a painting (its figures, roles, key elements, which way it hangs and, when known, roughly where its light comes from), the cast who can pose (people and stand-ins), the props on hand and an optional note about the space, and returns a verdict (hang it, re-hang, or swap the painting), a casting row with a pose cue for every figure, a stand-in for every key element with its status, the light, the phone framing, four to seven shoot stops, a wall label and - for a swap - a painting the cast can fill.
The division of labour first: the page owns the painting notes, the draft casting, the keyword prop guess and the hanging rule; the model owns the plan; the page re-checks the reply. All of it comes from mastkit.js, which you can run yourself (see Build the input yourself).
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":"after-the-masters"}), 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, painting, orientation, figures, roles, elements, light, cast, cast_count, props, catalogue, draft) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: a plan is metered and needs a personal 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, the statuses and the verdict
Every run sends task: restage; a missing or different one is still answered as restage. Each key element gets one status: covered (a listed prop, a stand-in or the shooting place plays it), improvise (make or borrow something simple) or cut (leave it out of frame).
The verdict is checked in order, first match wins: swap if cast_count times 2 is less than figures; otherwise rehang (shown as "re-hang") if cast_count is less than figures or any element is improvise or cut; otherwise hang (shown as "hang it").
The input
Every value is a string (objects are never sent). app.js builds it with MastKit.buildInput(form).input.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always restage. |
painting | yes | The title, then the artist and year when known. |
orientation | yes | portrait, landscape or square. |
figures / roles | yes | How many figures, then one line per figure: <n> | <who>. |
elements | yes | The key elements, one per line: <n> | <element> (at most 8). |
light | yes | from the left (approximate) and so on, or not listed. |
cast / cast_count | yes | One line per cast member, <name> | person or <name> | stand-in (at most 12), and the count. |
props | yes | One prop per line, at most 30. |
space | no | A note on where the shoot happens, at most 300 characters. |
catalogue | yes | The other paintings the page knows: <title> | <n> figure(s) | <elements; ...> - the only titles a swap may suggest. |
draft | yes | The page's keyword guess: <n> | <element> | likely: <prop> or nothing listed. Only a guess. |
clip_note | no | What the page shortened, if anything. |
retry_note | no | Sent only on the page's single reformat retry. |
The reply
The reply is one JSON object in job.output.output. The page parses it with MastKit.parseReply and MastKit.normalize (a cut-off stream is closed by MastKit.closeJson and whatever parsed is shown).
{"task": "restage", "verdict": "hang | rehang | swap", "verdict_reason": "...", "title": "...", "medium": "...", "label": "...",
"cast": [{"n": 1, "figure": "...", "played_by": "... or empty", "pose": "..."}],
"swaps": [{"n": 1, "element": "...", "use": "...", "status": "covered | improvise | cut"}],
"light": {"side": "left | right | front | above | behind", "setup": "..."},
"frame": {"orientation": "portrait | landscape | square", "camera": "...", "crop": "..."},
"stops": ["..."],
"alternative": null}
| Field | What it holds |
|---|---|
cast | One row per figure: who plays it (copied exactly from cast, or empty when nobody is left) and a pose cue of at most 25 words. |
swaps | One row per key element: the stand-in and its status. |
light / frame | The side the light comes from and how to get it; the orientation, phone height, distance, lens and crop. |
stops | Four to seven shoot steps, each at most 20 words - the page plays them on its audio-guide handset. |
title / medium / label | The wall label: a title of at most 8 words, materials from the props and cast, and two sentences of at most 45 words. |
alternative | {"painting", "why"} for a swap - a catalogue title the cast can fill - else null. |
The page then checks the reply against the brief (MastKit.check): the verdict against the hanging rule, one casting row per figure, every name in the cast and nobody cast twice, every covered stand-in sharing a word with a listed prop, cast member or the place, the light side against the painting's, the frame's orientation, four to seven stops, a swap painting from the catalogue that the cast can fill, no he or she for anyone and no internal field name in the prose.
Worked examples, one per verdict
These are the three free samples on the page: the exact input and a stand-in model's reply, unedited.
The farmhouse pair - hang it
Input:
{
"task": "restage",
"painting": "American Gothic - Grant Wood, 1930",
"orientation": "portrait",
"figures": "2",
"roles": "1 | the farmer holding the pitchfork\n2 | the companion at the farmer's shoulder",
"elements": "1 | a pitchfork held upright\n2 | round glasses\n3 | a dark jacket\n4 | a patterned apron\n5 | a pointed-arch window behind",
"light": "not listed",
"cast": "Marta | person\nMr Buttons, the tailor's dummy | stand-in",
"cast_count": "2",
"props": "garden fork\nwire-rimmed reading glasses\nblack work jacket\nflowered kitchen apron\nfront door with an arched glass panel\nwhite shirt with a round collar",
"space": "The hallway by the front door. Daylight comes in through the door's glass panel.",
"catalogue": "Mona Lisa | 1 figure | a dark dress; a thin veil over the hair; a chair arm to rest the hands on; a hazy landscape behind\nGirl with a Pearl Earring | 1 figure | a blue and yellow head wrap; a large pearl earring; a brownish jacket with a white collar; a plain dark background\nThe Milkmaid | 1 figure | a jug pouring into a bowl; bread on the table; a white cap; a yellow bodice and a blue apron; a window on the left\nArrangement in Grey and Black No. 1 | 1 figure | a straight-backed chair; a long black dress; a white lace cap; a dark curtain at one side; a framed picture on the wall\nThe Scream | 1 figure | a swirling orange sky; a long railing running away; two small figures far behind; dark water below\nWanderer above the Sea of Fog | 1 figure | a dark coat; a walking stick; a rocky high point to stand on; fog below\nThe Arnolfini Portrait | 2 figures | a green dress; a hanging chandelier; a round mirror on the back wall; a small dog at the front; a window on the left\nThe Gleaners | 3 figures | bundles of gathered stalks; head scarves; a wide flat field; haystacks far behind",
"draft": "1 | a pitchfork held upright | likely: garden fork\n2 | round glasses | likely: wire-rimmed reading glasses\n3 | a dark jacket | likely: black work jacket\n4 | a patterned apron | likely: flowered kitchen apron\n5 | a pointed-arch window behind | likely: front door with an arched glass panel"
}
Reply:
{
"task": "restage",
"verdict": "hang",
"verdict_reason": "One person and one stand-in fill both figures, and all five elements come from the props list.",
"title": "American Gothic, Front Door Edition",
"medium": "Garden fork, reading glasses, work jacket, kitchen apron and tailor's dummy.",
"label": "In the hallway, Marta and Mr Buttons the tailor's dummy recreate American Gothic, garden fork held upright before the arched glass of the front door. Reading glasses, a work jacket and a kitchen apron complete the pair, caught in daylight from the doorway.",
"cast": [
{
"n": 1,
"figure": "the farmer holding the pitchfork",
"played_by": "Marta",
"pose": "Marta stands upright, holds the garden fork in the right hand, point up, and looks straight ahead at the camera."
},
{
"n": 2,
"figure": "the companion at the farmer's shoulder",
"played_by": "Mr Buttons, the tailor's dummy",
"pose": "Mr Buttons stands beside Marta at shoulder height, upright, facing forward toward the camera."
}
],
"swaps": [
{
"n": 1,
"element": "a pitchfork held upright",
"use": "garden fork, held upright in the right hand",
"status": "covered"
},
{
"n": 2,
"element": "round glasses",
"use": "wire-rimmed reading glasses, worn on the face",
"status": "covered"
},
{
"n": 3,
"element": "a dark jacket",
"use": "black work jacket, worn over the shoulders",
"status": "covered"
},
{
"n": 4,
"element": "a patterned apron",
"use": "flowered kitchen apron, tied at the waist",
"status": "covered"
},
{
"n": 5,
"element": "a pointed-arch window behind",
"use": "front door with an arched glass panel, placed behind the figures",
"status": "covered"
}
],
"light": {
"side": "behind",
"setup": "Stand with backs to the arched glass panel in the front door so daylight lights them from behind; add a lamp or window on the front side to fill faces."
},
"frame": {
"orientation": "portrait",
"camera": "Hold the phone upright at chest height, step back about six feet, and use the main lens, not the wide one.",
"crop": "Frame from the top of the heads to the waist, keeping both figures centered and the glass door panel visible behind them."
},
"stops": [
"Set up in the hallway with the front door's arched glass panel as the backdrop behind the pair.",
"Dress Marta in the black work jacket and flowered kitchen apron, with the wire-rimmed reading glasses ready.",
"Position Mr Buttons at Marta's shoulder, upright, standing in for the companion figure.",
"Hand Marta the garden fork, held upright, point up and away from faces.",
"Let the glass panel light them from behind; add a lamp or window on the front side.",
"Shoot on the phone's main lens, holding it at chest height, framed head to waist.",
"Compare the photo with American Gothic, checking pose, props and light before finishing."
],
"alternative": null
}
The pearl, minus the pearl - re-hang
Input:
{
"task": "restage",
"painting": "Girl with a Pearl Earring - Johannes Vermeer, c. 1665",
"orientation": "portrait",
"figures": "1",
"roles": "1 | the sitter glancing back over one shoulder",
"elements": "1 | a blue and yellow head wrap\n2 | a large pearl earring\n3 | a brownish jacket with a white collar\n4 | a plain dark background",
"light": "from the left (approximate)",
"cast": "Priya | person",
"cast_count": "1",
"props": "blue silk scarf\nyellow pillowcase\nbrown corduroy jacket\nwhite collared shirt\ndesk lamp",
"space": "A small bedroom with one window. Free to shoot in the evenings.",
"catalogue": "American Gothic | 2 figures | a pitchfork held upright; round glasses; a dark jacket; a patterned apron; a pointed-arch window behind\nMona Lisa | 1 figure | a dark dress; a thin veil over the hair; a chair arm to rest the hands on; a hazy landscape behind\nThe Milkmaid | 1 figure | a jug pouring into a bowl; bread on the table; a white cap; a yellow bodice and a blue apron; a window on the left\nArrangement in Grey and Black No. 1 | 1 figure | a straight-backed chair; a long black dress; a white lace cap; a dark curtain at one side; a framed picture on the wall\nThe Scream | 1 figure | a swirling orange sky; a long railing running away; two small figures far behind; dark water below\nWanderer above the Sea of Fog | 1 figure | a dark coat; a walking stick; a rocky high point to stand on; fog below\nThe Arnolfini Portrait | 2 figures | a green dress; a hanging chandelier; a round mirror on the back wall; a small dog at the front; a window on the left\nThe Gleaners | 3 figures | bundles of gathered stalks; head scarves; a wide flat field; haystacks far behind",
"draft": "1 | a blue and yellow head wrap | likely: blue silk scarf\n2 | a large pearl earring | nothing listed\n3 | a brownish jacket with a white collar | likely: brown corduroy jacket\n4 | a plain dark background | nothing listed"
}
Reply:
{
"task": "restage",
"verdict": "rehang",
"verdict_reason": "One person stands in for the one figure, and two of the four elements have no exact match on the list.",
"title": "Girl with a Paper Pearl, Evening Edition",
"medium": "Blue silk scarf, yellow pillowcase, brown corduroy jacket and white collared shirt",
"label": "A scarf and a pillowcase trade places as a painter's head wrap, and a borrowed paper circle stands in for a pearl. Turned toward the lamp, the sitter looks back the way the painting asks, caught for one evening in corduroy and cotton.",
"cast": [
{
"n": 1,
"figure": "the sitter glancing back over one shoulder",
"played_by": "Priya",
"pose": "Priya turns the shoulders away from the camera, looks back over one shoulder toward the lens, chin tucked slightly, hands resting below the frame."
}
],
"swaps": [
{
"n": 1,
"element": "a blue and yellow head wrap",
"use": "blue silk scarf and yellow pillowcase, wrapped together as a head wrap",
"status": "covered"
},
{
"n": 2,
"element": "a large pearl earring",
"use": "borrow or fold a white paper circle as the earring",
"status": "improvise"
},
{
"n": 3,
"element": "a brownish jacket with a white collar",
"use": "brown corduroy jacket and white collared shirt, collar worn open over the jacket",
"status": "covered"
},
{
"n": 4,
"element": "a plain dark background",
"use": "crop in close so the background does not show",
"status": "cut"
}
],
"light": {
"side": "left",
"setup": "Place Priya side-on to the window in daylight, or use the desk lamp at head height on the left in the evening."
},
"frame": {
"orientation": "portrait",
"camera": "Hold the phone upright at face height, about an arm's length away, using the main lens, not the wide one.",
"crop": "Frame head and shoulders only, cropping tight so the background stays out of the shot."
},
"stops": [
"Pick a corner of the bedroom and stand close so the frame crops the background out.",
"Dress Priya in the brown corduroy jacket over the white collared shirt, collar turned out.",
"Wrap the blue silk scarf and yellow pillowcase as the head wrap, then fold a paper circle for the earring.",
"Have Priya turn and look back over one shoulder, chin tucked slightly.",
"Turn Priya toward the window or desk lamp so the light comes from the left.",
"Hold the phone at face height with the main lens and take the photo.",
"Compare the photo with the painting and adjust the head wrap, collar or light."
],
"alternative": null
}
Three gleaners, one Lou - swap the painting
Input:
{
"task": "restage",
"painting": "The Gleaners - Jean-Francois Millet, 1857",
"orientation": "landscape",
"figures": "3",
"roles": "1 | the gleaner bent lowest\n2 | the second bent gleaner\n3 | the gleaner half standing",
"elements": "1 | bundles of gathered stalks\n2 | head scarves\n3 | a wide flat field\n4 | haystacks far behind",
"light": "not listed",
"cast": "Lou | person",
"cast_count": "1",
"props": "straw sun hat\ngreen headscarf\narmful of dried grasses\nlong brown skirt\nphone tripod",
"space": "A mown field at the edge of the village.",
"catalogue": "American Gothic | 2 figures | a pitchfork held upright; round glasses; a dark jacket; a patterned apron; a pointed-arch window behind\nMona Lisa | 1 figure | a dark dress; a thin veil over the hair; a chair arm to rest the hands on; a hazy landscape behind\nGirl with a Pearl Earring | 1 figure | a blue and yellow head wrap; a large pearl earring; a brownish jacket with a white collar; a plain dark background\nThe Milkmaid | 1 figure | a jug pouring into a bowl; bread on the table; a white cap; a yellow bodice and a blue apron; a window on the left\nArrangement in Grey and Black No. 1 | 1 figure | a straight-backed chair; a long black dress; a white lace cap; a dark curtain at one side; a framed picture on the wall\nThe Scream | 1 figure | a swirling orange sky; a long railing running away; two small figures far behind; dark water below\nWanderer above the Sea of Fog | 1 figure | a dark coat; a walking stick; a rocky high point to stand on; fog below\nThe Arnolfini Portrait | 2 figures | a green dress; a hanging chandelier; a round mirror on the back wall; a small dog at the front; a window on the left",
"draft": "1 | bundles of gathered stalks | nothing listed\n2 | head scarves | nothing listed\n3 | a wide flat field | nothing listed\n4 | haystacks far behind | nothing listed"
}
Reply:
{
"task": "restage",
"verdict": "swap",
"verdict_reason": "One person cast for three figures, fewer than half the figures needed, so the painting is swapped.",
"title": "The Lone Gleaner, Field Edition",
"medium": "Long brown skirt, green headscarf, and an armful of dried grasses.",
"label": "After Millet's The Gleaners, one gleaner now bends low over a mown field, an armful of dried grasses gathered beneath a green headscarf. The wide harvest becomes a single gesture, held at the village's edge.",
"cast": [
{
"n": 1,
"figure": "the gleaner bent lowest",
"played_by": "Lou",
"pose": "Lou bends low at the waist, knees bent, eyes down toward the field, arms reaching to gather the armful of dried grasses."
},
{
"n": 2,
"figure": "the second bent gleaner",
"played_by": "",
"pose": "Left out of frame; only Lou appears, so the second bent gleaner is not shown in this shoot."
},
{
"n": 3,
"figure": "the gleaner half standing",
"played_by": "",
"pose": "Also left out of frame; the gleaner half standing is not part of this shoot with only Lou cast."
}
],
"swaps": [
{
"n": 1,
"element": "bundles of gathered stalks",
"use": "Armful of dried grasses, bundled and held in both arms",
"status": "covered"
},
{
"n": 2,
"element": "head scarves",
"use": "Green headscarf, tied snugly over the hair",
"status": "covered"
},
{
"n": 3,
"element": "a wide flat field",
"use": "The mown field at the edge of the village, filling the background",
"status": "covered"
},
{
"n": 4,
"element": "haystacks far behind",
"use": "Crop the frame before any haystacks would show in the distance",
"status": "cut"
}
],
"light": {
"side": "left",
"setup": "Outdoors in the field, turn so daylight comes from the left, giving sidelight across the bent pose."
},
"frame": {
"orientation": "landscape",
"camera": "Hold the phone horizontal at chest height, step back enough to fit Lou and the open field, using the main lens.",
"crop": "The frame holds Lou bent low over the dried grasses in the mown field; crop out any sky above."
},
"stops": [
"Walk to the mown field at the edge of the village and pick the open stretch as the backdrop.",
"Dress Lou in the long brown skirt and tie the green headscarf snugly over the hair.",
"Gather the armful of dried grasses and set the phone tripod low in the field.",
"Have Lou bend low at the waist, eyes down, arms reaching for the gathered grasses.",
"Turn so daylight falls from the left, giving sidelight across the bent pose.",
"Shoot landscape on the main lens, framing Lou and the field, leaving sky out.",
"Compare the shot with The Gleaners, checking the bend, the scarf and the field."
],
"alternative": {
"painting": "Mona Lisa",
"why": "A green headscarf could stand in for the thin veil over the hair, and the long brown skirt could suggest the dark dress."
}
}
Build the input yourself
Load the script in node (global.window = global; require("./mastkit.js")), then:
const K = MastKit;
const form = K.form({ painting: "milkmaid", cast: "Ana", standins: "",
props: "white jug\nmixing bowl\nloaf of bread\nyellow cardigan\nblue apron\nwhite cotton cap", space: "Kitchen table by a window" });
K.painting(form); // {title: "The Milkmaid", figures: 1, roles: [...], elements: [...], light: "left", ...}
const built = K.buildInput(form);
built.page; // the page's hanging-rule guess: "hang", "rehang" or "swap"
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the plan
// K.md(built, reply) // the casting, props, light, framing and tour as Markdown
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://after-the-masters.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://after-the-masters.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"]
// after-the-masters.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://after-the-masters.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://after-the-masters.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
}
// AfterTheMasters.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 AfterTheMasters {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://after-the-masters.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://after-the-masters.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://after-the-masters.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://after-the-masters.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":"after-the-masters"}' | 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": "after-the-masters"})["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: "after-the-masters" })).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": "after-the-masters"}, 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", "after-the-masters"), 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: "after-the-masters" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "after-the-masters"])["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 = "after-the-masters" }))!["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": "restage", "painting": "Girl with a Pearl Earring - Johannes Vermeer, c. 1665", "figures": "1", "roles": "1 | ...", "elements": "1 | ...", "cast": "Priya | person", "cast_count": "1", "props": "blue silk scarf\n...", "draft": "1 | ... | likely: ..."}
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": "restage", "painting": "Girl with a Pearl Earring - Johannes Vermeer, c. 1665", "figures": "1", "roles": "1 | ...", "elements": "1 | ...", "cast": "Priya | person", "cast_count": "1", "props": "blue silk scarf\n...", "draft": "1 | ... | likely: ..."}
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: "restage", painting, figures, roles, elements, cast, props, catalogue, draft, ...} (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 after-the-masters:<task>:<inputHash>:a<attempt> (the one task is restage), 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="after-the-masters:restage:$(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 = "after-the-masters:" + 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"))
import { createHash } from "node:crypto";
const key = `after-the-masters:${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);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "after-the-masters:" + 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 = "after-the-masters:" + 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 = "after-the-masters:#{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 = "after-the-masters:" . $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 = $"after-the-masters:{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: after-the-masters:restage:$(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": "restage", "painting": "Girl with a Pearl Earring - Johannes Vermeer, c. 1665", "figures": "1", "roles": "1 | ...", "elements": "1 | ...", "cast": "Priya | person", "cast_count": "1", "props": "blue silk scarf\n...", "draft": "1 | ... | likely: ..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "after-the-masters:restage:" + 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": `after-the-masters:restage:${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", "after-the-masters:restage:"+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", "after-the-masters:restage:" + 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" => "after-the-masters:restage:#{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: after-the-masters:restage:" . 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", $"after-the-masters:restage:{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);