Drive Button Wall from your own code
The one metered read is available over HTTP: give it a site's name, an optional link, what the site is about, a few vibe words and (optionally) your own draft 88x31 button, and it returns a verdict, exactly three button designs as specs a renderer can draw (frames of capital-letter lines, a pixel font, a fill style, four colours, an icon and a frame time), three to five notes on message, legibility, colour, motion and style, the changes made to your draft, alt text and a link title for the embed code. A natural use is a static-site generator or a webring tool that wants a ready-made button for each member.
The division of labour first: the page owns the pixels; the model picks words and colours; the page re-checks the reply. The two bitmap fonts, the per-line fit, the contrast ratio and the verdict (by the same ordered rules the model gets) come from pixfont.js and btnkit.js, which run in node as well as in the browser, and gifenc.js turns any spec into a GIF or PNG. BtnKit.check holds each reply to the pixels: three buttons, every line inside its font's width, no characters the fonts cannot draw, text contrast of 3:1 or more, a first button that really is your draft trimmed or recolored when the verdict says so, and no internal field names in prose.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"button-wall"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field such as task or site is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the three designs 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
Every run sends task: button - there is one lane. The verdict is decided by five ordered, non-overlapping rules; the first that matches wins:
| Order | Verdict | When |
|---|---|---|
| 1 | fresh | No draft words: three new buttons are designed from the brief. |
| 2 | rethink | The draft's words never name the site, its link or a topic word from the description. |
| 3 | trim | A line is wider than its font, a frame has too many lines, or there are more than 4 frames. |
| 4 | recolor | The text's worst contrast against its fill is under 3:1. |
| 5 | ship | None of the above: button 1 is the draft as given, or with at most two small fixes. |
The declared input schema carries scalars only, so array-valued fields travel JSON-encoded as strings (BtnKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | button. |
site | string | The site's name, up to 40 characters (Moss & Mortar). |
url | string | Optional link, up to 120 characters. Used in the embed code and as a name source. |
about | string | What the site is. Past 600 characters whole sentences are cut from the middle with a marker. |
vibe | string | Optional mood words, up to 160 characters. |
draft_frames | string (JSON) | Array of strings, one per frame: "frame 1: TINY ZINE / CLUB" ( / stacks lines). Empty array for no draft. |
draft_look | string | The draft's font, style, colours, icon and frame time in words; empty for no draft. |
page_fit | string (JSON) | Array: per draft line, "frame 1 line 2: 14 of 12 characters - 2 too many". |
page_contrast | string | The draft's worst text contrast, e.g. worst text contrast 1.5:1 (text #9dbf9e on #7a9e7e). |
page_capacity | string | The font limits: tall 13 characters (12 with an icon) by 3 lines, tiny 20 (18) by 3 lines, 4 frames. |
page_lean | string | The page's verdict, by the rules above. |
page_flags | string (JSON) | Array of the page's warnings (an overflowing line, a character the fonts cannot draw, a frame that flips too fast). |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, buttons (exactly three {name, idea, frames: [[line, ...], ...], font, style, bg, bg2, fg, border, icon, side, speed}; font tall or tiny; style one of flat, bevel, inset, stripe, gradient, dither; icon one of none, heart, star, moon, sun, leaf, note, bolt, cup, eye, globe), read ([{aspect, note}], 3-5, aspect one of message, legibility, color, motion, style), changes ([{part, was, now, why}], empty for fresh and rethink), alt_text and link_title. Parse it with BtnKit.parseReply, then normalize(reply), then check(reply, form), which returns {notes, per}.
Worked examples
Replies produced against the app's prompt for the five samples (made-up sites; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict.
a stone-wall blog, no draft - fresh
Input:
{
"task": "button",
"site": "Moss & Mortar",
"url": "https://mossandmortar.example/",
"about": "A blog about building and repairing dry stone walls by hand, one field boundary at a time, with notes on the tools and t ... (130 characters)",
"vibe": "earthy, slow, handmade",
"draft_frames": "[]",
"draft_look": "",
"page_fit": "[]",
"page_contrast": "",
"page_capacity": "tall font: 13 characters per line (12 with an icon), up to 3 lines; tiny font: 20 characte ... (225 characters)",
"page_lean": "fresh",
"page_flags": "[]"
}
Reply:
{
"task": "button",
"verdict": "fresh",
"title": "Moss & Mortar: Dry Stone, By Hand",
"summary": "There's no draft to build on, so here are three fresh buttons for Moss & Mortar's blog about building and repairing dry stone walls by hand. One is a static moss-green bevel that pairs the site name with STONE WALLS, one is an earthy striped button with a leaf mark that animates between the site name and BUILDING BY HAND, and one is a slower stone-grey dither with a note mark that spells out DRY STONE WALLS and ONE AT A TIME. All three stick to the site name, the brief's own words, and its earthy, slow, handmade vibe.",
"buttons": [
{
"name": "Moss Bevel",
"idea": "A plain raised bevel in deep moss green with pale cream text keeps the site name and the dry stone walls topic easy to read at actual size.",
"frames": [
[
"MOSS & MORTAR",
"STONE WALLS"
]
],
"font": "tall",
"style": "bevel",
"bg": "#2e3a1f",
"bg2": "#2e3a1f",
"fg": "#f5efd6",
"border": "#15200c",
"icon": "none",
"side": "left",
"speed": 1300
},
{
"name": "Earthy Stripe Leaf",
"idea": "A warm brown stripe texture with a small leaf mark carries the earthy, handmade vibe, animating between the site name and a nod to building the walls by hand.",
"frames": [
[
"MOSS & MORTAR"
],
[
"BUILDING BY HAND"
]
],
"font": "tiny",
"style": "stripe",
"bg": "#4a4038",
"bg2": "#3a2f28",
"fg": "#f5efd6",
"border": "#231c17",
"icon": "leaf",
"side": "right",
"speed": 1000
},
"... (3 in all)"
],
"read": [
{
"aspect": "message",
"note": "All three buttons stick to words from your brief: the full name MOSS & MORTAR, the topic words STONE WALLS and DRY STONE WALLS, and the phrase BY HAND from your description, with nothing invented."
},
{
"aspect": "legibility",
"note": "Button 1 splits MOSS & MORTAR (13 characters) and STONE WALLS (11 characters) across two tall-font lines, both at or under the 13-character cap for that font with no icon."
},
"... (5 in all)"
],
"changes": [],
"alt_text": "Moss & Mortar - 88x31 button with the site name over STONE WALLS",
"link_title": "Moss & Mortar - dry stone walls by hand"
}
a recipe site whose button says only CLICK HERE - rethink
Input:
{
"task": "button",
"site": "Pixel Pantry",
"url": "https://pixelpantry.example/",
"about": "Home recipes cooked on a two-burner camping stove, tested on weekend trips.",
"vibe": "cheerful, warm",
"draft_frames": "[\"frame 1: CLICK HERE!\",\"frame 2: COOL SITE\"]",
"draft_look": "font tall, style stripe, fill #ffcc00, second fill #ff6600, text #000000, border #000000, icon star on the left, frame time 900 ms",
"page_fit": "[\"frame 1 line 1: 11 of 12 characters - fits\",\"frame 2 line 1: 9 of 12 characters - fits\"]",
"page_contrast": "worst text contrast 7.2:1 (text #000000 on #ffcc00 and #ff6600)",
"page_capacity": "tall font: 13 characters per line (12 with an icon), up to 3 lines; tiny font: 20 characte ... (225 characters)",
"page_lean": "rethink",
"page_flags": "[]"
}
Reply:
{
"task": "button",
"verdict": "rethink",
"title": "Pixel Pantry Buttons That Name The Site",
"summary": "This is a rethink: your draft's CLICK HERE! and COOL SITE frames never say PIXEL PANTRY or mention recipes, camping or the stove, so a visitor can't tell whose site this is. All three buttons below work in words from your brief - the site name plus HOME, RECIPES, CAMPING and STOVE. The first keeps your yellow-orange stripe, black text and left star; the other two try a cream bevel with a cup and an orange-to-yellow gradient with a sun to match the cheerful, warm vibe.",
"buttons": [
{
"name": "Golden Stripe Pantry",
"idea": "Keeps your warm yellow-orange stripe and star but swaps in PIXEL PANTRY and HOME RECIPES so visitors know whose site it is.",
"frames": [
[
"PIXEL PANTRY"
],
[
"HOME RECIPES"
]
],
"font": "tall",
"style": "stripe",
"bg": "#ffcc00",
"bg2": "#ff6600",
"fg": "#000000",
"border": "#000000",
"icon": "star",
"side": "left",
"speed": 900
},
{
"name": "Cream Cup Bevel",
"idea": "A soft cream bevel with a cup icon names the site and its cooking topic in one cheerful, easy-to-read pair of lines.",
"frames": [
[
"PIXEL PANTRY",
"CAMPING RECIPES"
]
],
"font": "tiny",
"style": "bevel",
"bg": "#fff4e0",
"bg2": "#fff4e0",
"fg": "#2b1700",
"border": "#2b1700",
"icon": "cup",
"side": "right",
"speed": 1500
},
"... (3 in all)"
],
"read": [
{
"aspect": "message",
"note": "Your draft's CLICK HERE! and COOL SITE never name PIXEL PANTRY or mention recipes, camping or the stove, so it reads as a generic link button, not yours."
},
{
"aspect": "legibility",
"note": "Both draft lines already fit the tall font with the star (11 of 12 and 9 of 12 characters), so length was never the problem - the words were."
},
"... (5 in all)"
],
"changes": [],
"alt_text": "Pixel Pantry - 88x31 button with a star, reads PIXEL PANTRY and HOME RECIPES",
"link_title": "Pixel Pantry - home recipes on a camping stove"
}
a mix show whose words overflow the font - trim
Input:
{
"task": "button",
"site": "Late Owl Mixes",
"url": "https://lateowl.example/",
"about": "A weekly ambient music mix posted every Sunday night.",
"vibe": "calm, nocturnal, dreamy",
"draft_frames": "[\"frame 1: LATE OWL MIXES / AMBIENT EVERY SUNDAY NIGHT\"]",
"draft_look": "font tall, style gradient, fill #0b1026, second fill #3a2f6b, text #e8e6ff, border #000000, icon moon on the left, frame time 1500 ms",
"page_fit": "[\"frame 1 line 1: 14 of 12 characters - 2 too many\",\"frame 1 line 2: 26 of 12 characters - 14 too many\"]",
"page_contrast": "worst text contrast 9.6:1 (text #e8e6ff on #0b1026 and #3a2f6b)",
"page_capacity": "tall font: 13 characters per line (12 with an icon), up to 3 lines; tiny font: 20 characte ... (225 characters)",
"page_lean": "trim",
"page_flags": "[\"Frame 1 line 1 \\\"LATE OWL MIXES\\\" is 14 characters; 12 fit - 2 get clipped.\",\"Frame 1 line 2 \\\"AMBIENT EVERY SUNDAY NIGHT\\\" is 26 characters; 12 fit - 14 get ... (170 characters)"
}
Reply:
{
"task": "button",
"verdict": "trim",
"title": "Late Owl Mixes: Trimmed Dusk Gradient",
"summary": "This is a trim: your draft's two lines run 2 and 14 characters over the tall font's 12-character limit with the moon icon, so the page's check is right. Button 1 keeps your gradient, colours, border and moon icon but wraps the name and the schedule across two frames so every character clears the limit; buttons 2 and 3 offer a quieter single-frame bevel in the tiny font and a plain two-line card, both built only from your own words.",
"buttons": [
{
"name": "Dusk Gradient Wrap",
"idea": "Keeping your dusk-to-indigo gradient, black border and left-side moon keeps the night mood you already built, so wrapping the words across two frames is the only change needed to clear the character limit.",
"frames": [
[
"LATE OWL",
"MIXES"
],
[
"AMBIENT",
"SUNDAY NIGHT"
]
],
"font": "tall",
"style": "gradient",
"bg": "#0b1026",
"bg2": "#3a2f6b",
"fg": "#e8e6ff",
"border": "#000000",
"icon": "moon",
"side": "left",
"speed": 1500
},
{
"name": "Calm Night Bevel",
"idea": "A single flat-bevel card in a deep indigo lets the tiny font hold both the full name and the word WEEKLY in one still image, matching a calm, nocturnal listen.",
"frames": [
[
"LATE OWL MIXES",
"WEEKLY AMBIENT MIX"
]
],
"font": "tiny",
"style": "bevel",
"bg": "#1b1b3a",
"bg2": "#1b1b3a",
"fg": "#f4f1ff",
"border": "#000000",
"icon": "star",
"side": "right",
"speed": 1600
},
"... (3 in all)"
],
"read": [
{
"aspect": "message",
"note": "Your draft's \"LATE OWL MIXES\" names the site outright and \"AMBIENT EVERY SUNDAY NIGHT\" states the weekly schedule - the words already carry the right facts, just too many for one line."
},
{
"aspect": "legibility",
"note": "At 14 and 26 characters, both of your draft's lines overrun the tall font's 12-character limit with the moon icon; wrapping to \"LATE OWL\" / \"MIXES\" and \"AMBIENT\" / \"SUNDAY NIGHT\" clears the limit without losing either phrase."
},
"... (5 in all)"
],
"changes": [
{
"part": "text",
"was": "LATE OWL MIXES",
"now": "LATE OWL / MIXES",
"why": "14 characters do not fit the tall font's 12-character limit with the icon, so the name wraps onto two lines."
},
{
"part": "text",
"was": "AMBIENT EVERY SUNDAY NIGHT",
"now": "AMBIENT / SUNDAY NIGHT",
"why": "26 characters is far over the 12-character limit, so the line drops EVERY and wraps the rest to fit."
},
"... (3 in all)"
],
"alt_text": "Late Owl Mixes - 88x31 gradient button with a moon icon, reads LATE OWL MIXES then AMBIENT SUNDAY NIGHT.",
"link_title": "Late Owl Mixes - weekly ambient mix, Sunday night"
}
a fern journal with pale-on-pale text - recolor
Input:
{
"task": "button",
"site": "Fern Notes",
"url": "https://fernnotes.example/",
"about": "A field journal of ferns spotted on local woodland walks, with sketches.",
"vibe": "quiet, green, botanical",
"draft_frames": "[\"frame 1: FERN / NOTES\"]",
"draft_look": "font tall, style flat, fill #7a9e7e, second fill #7a9e7e, text #9dbf9e, border #3e5c43, icon leaf on the left, frame time 1200 ms",
"page_fit": "[\"frame 1 line 1: 4 of 12 characters - fits\",\"frame 1 line 2: 5 of 12 characters - fits\"]",
"page_contrast": "worst text contrast 1.5:1 (text #9dbf9e on #7a9e7e)",
"page_capacity": "tall font: 13 characters per line (12 with an icon), up to 3 lines; tiny font: 20 characte ... (225 characters)",
"page_lean": "recolor",
"page_flags": "[]"
}
Reply:
{
"task": "button",
"verdict": "recolor",
"title": "Fern Notes: Quiet Woodland Botanical Read",
"summary": "This is a recolor: your words already say FERN and NOTES, and both lines fit, but the pale green text barely lifts off the pale green fill (1.5:1). Button 1 keeps your draft's words, font, style and leaf icon but moves to a dark forest fill with light text for about 6:1 contrast. Buttons 2 and 3 are alternatives: an animated cream bevel that adds the woodland-walks line from your about text, and a striped dark-green button with a notebook icon for the sketches.",
"buttons": [
{
"name": "Forest Green Fix",
"idea": "Keeps your FERN / NOTES layout and leaf icon but swaps to a dark forest fill so the pale text finally stands out.",
"frames": [
[
"FERN",
"NOTES"
]
],
"font": "tall",
"style": "flat",
"bg": "#3e5c43",
"bg2": "#3e5c43",
"fg": "#d9ecd4",
"border": "#24371f",
"icon": "leaf",
"side": "left",
"speed": 1200
},
{
"name": "Woodland Walk Bevel",
"idea": "A soft cream bevel that cycles between your site name and the woodland walks from your about line, easy to read in the tiny font.",
"frames": [
[
"FERN NOTES"
],
[
"WOODLAND WALKS"
]
],
"font": "tiny",
"style": "bevel",
"bg": "#f4efe3",
"bg2": "#f4efe3",
"fg": "#2f4a33",
"border": "#2f4a33",
"icon": "leaf",
"side": "right",
"speed": 900
},
"... (3 in all)"
],
"read": [
{
"aspect": "color",
"note": "Your draft's text #9dbf9e sits almost on top of its fill #7a9e7e (1.5:1); button 1 moves to a #3e5c43 fill with #d9ecd4 text for about 6:1."
},
{
"aspect": "message",
"note": "FERN and NOTES already spell out the site name, so button 1 and 3 keep it, and button 2 adds WOODLAND WALKS from your about line."
},
"... (5 in all)"
],
"changes": [
{
"part": "color",
"was": "fill #7a9e7e, text #9dbf9e, border #3e5c43 (worst contrast 1.5:1)",
"now": "fill #3e5c43, text #d9ecd4, border #24371f (about 6:1)",
"why": "The draft's text and fill were nearly the same pale green; pairing a dark fill with light text clears the 4.5:1 bar."
}
],
"alt_text": "Fern Notes - 88x31 button reading FERN NOTES with a leaf icon",
"link_title": "Fern Notes - a field journal of ferns and sketches"
}
a zine club whose button already works - ship
Input:
{
"task": "button",
"site": "Tiny Zine Club",
"url": "https://tinyzine.example/",
"about": "A members' page for a club that swaps photocopied zines by post every month.",
"vibe": "punk, photocopied, friendly",
"draft_frames": "[\"frame 1: TINY ZINE / CLUB\",\"frame 2: SWAP BY POST\"]",
"draft_look": "font tall, style flat, fill #f2f2f2, second fill #f2f2f2, text #111111, border #111111, icon heart on the right, frame time 1600 ms",
"page_fit": "[\"frame 1 line 1: 9 of 12 characters - fits\",\"frame 1 line 2: 4 of 12 characters - fits\",\"frame 2 line 1: 12 of 12 characters - fits\"]",
"page_contrast": "worst text contrast 16.9:1 (text #111111 on #f2f2f2)",
"page_capacity": "tall font: 13 characters per line (12 with an icon), up to 3 lines; tiny font: 20 characte ... (225 characters)",
"page_lean": "ship",
"page_flags": "[]"
}
Reply:
{
"task": "button",
"verdict": "ship",
"title": "Tiny Zine Club: Ready To Ship",
"summary": "This ships: your draft already spells out the club in both frames, every line sits at or under the tall font's limit with its heart icon, and the text holds 16.9:1 contrast against the fill. Button 1 keeps your draft exactly as it is. Buttons 2 and 3 try a punk, colour-inverted bevel in the tiny font and a photocopied-looking dithered still, both built from the club's own words.",
"buttons": [
{
"name": "Tiny Zine Original",
"idea": "Your own flat black-on-white design already spells out the club's name and fits the tall font, so it ships unchanged.",
"frames": [
[
"TINY ZINE",
"CLUB"
],
[
"SWAP BY POST"
]
],
"font": "tall",
"style": "flat",
"bg": "#f2f2f2",
"bg2": "#f2f2f2",
"fg": "#111111",
"border": "#111111",
"icon": "heart",
"side": "right",
"speed": 1600
},
{
"name": "Punk Zine Flip",
"idea": "Inverting the draft's colours into a raised bevel and moving to the tiny font lets the club's punk and friendly words take three quick turns.",
"frames": [
[
"PUNK ZINE CLUB"
],
[
"SWAP BY POST"
],
[
"FRIENDLY CLUB"
]
],
"font": "tiny",
"style": "bevel",
"bg": "#111111",
"bg2": "#111111",
"fg": "#f2f2f2",
"border": "#f2f2f2",
"icon": "bolt",
"side": "left",
"speed": 900
},
"... (3 in all)"
],
"read": [
{
"aspect": "message",
"note": "Both frames name the club outright - 'TINY ZINE' and 'CLUB' on frame one, 'SWAP BY POST' on frame two - so the words alone say what the site is."
},
{
"aspect": "legibility",
"note": "Every line sits at or under the tall font's 12-character limit with the heart icon, including 'SWAP BY POST' landing exactly on the limit at 12, so nothing gets clipped."
},
"... (5 in all)"
],
"changes": [],
"alt_text": "Tiny Zine Club - 88x31 button with a heart, reads TINY ZINE / CLUB then SWAP BY POST",
"link_title": "Tiny Zine Club - swaps photocopied zines by post"
}
Build the input yourself
Load the scripts in node (global.window = global; require("./pixfont.js"); require("./btnkit.js"); require("./gifenc.js")), then:
const K = BtnKit, P = PixFont;
const form = { site: "Fern Notes", url: "https://fernnotes.example/", about: "A field journal of ferns spotted on local woodland walks.",
vibe: "quiet, green", frames: "FERN / NOTES", font: "tall", style: "flat", bg: "#7a9e7e", bg2: "#7a9e7e",
fg: "#9dbf9e", border: "#3e5c43", icon: "leaf", side: "left", speed: "1200" };
console.log(K.lean(form).verdict, K.lean(form).why); // the page's verdict: recolor, contrast 1.5:1
const wire = K.toWire(K.buildInput(form).input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(reply, form); // the page's re-check of every designed button
// const a = P.renderAll(K.buttonSpec(reply.buttons[0])); // 88x31 palette-index frames
// GifEnc.gif(a.w, a.h, a.palette, a.frames, a.delay) // the animated GIF bytes
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://button-wall.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://button-wall.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"]
// button-wall.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://button-wall.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://button-wall.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
}
// ButtonWall.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 ButtonWall {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://button-wall.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://button-wall.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://button-wall.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://button-wall.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":"button-wall"}' | 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": "button-wall"})["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: "button-wall" })).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": "button-wall"}, 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", "button-wall"), 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: "button-wall" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "button-wall"])["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 = "button-wall" }))!["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": "button", "site": "Moss & Mortar", "url": "https://mossandmortar.example/", "about": "A blog about building and repairing dry stone walls by hand...", "vibe": "earthy, slow, handmade", "draft_frames": "[]", "draft_look": "", "page_fit": "[]", "page_contrast": "", "page_capacity": "tall font: 13 characters per line ...", "page_lean": "fresh", "page_flags": "[]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "button", "site": "Moss & Mortar", "url": "https://mossandmortar.example/", "about": "A blog about building and repairing dry stone walls by hand...", "vibe": "earthy, slow, handmade", "draft_frames": "[]", "draft_look": "", "page_fit": "[]", "page_contrast": "", "page_capacity": "tall font: 13 characters per line ...", "page_lean": "fresh", "page_flags": "[]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "button", site, url, about, vibe, draft_frames, draft_look, page_fit, ... (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 button-wall:<task>:<inputHash>:a<attempt> (there is one lane, button), 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="button-wall:button:$(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 = "button-wall:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the read as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `button-wall:${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 := "button-wall:" + 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 = "button-wall:" + 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 = "button-wall:#{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 = "button-wall:" . $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 = $"button-wall:{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: button-wall:button:$(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": "button", "site": "Moss & Mortar", "url": "https://mossandmortar.example/", "about": "A blog about building and repairing dry stone walls by hand...", "vibe": "earthy, slow, handmade", "draft_frames": "[]", "draft_look": "", "page_fit": "[]", "page_contrast": "", "page_capacity": "tall font: 13 characters per line ...", "page_lean": "fresh", "page_flags": "[]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "button-wall:button:" + 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": `button-wall:button:${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", "button-wall:button:"+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", "button-wall:button:" + 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" => "button-wall:button:#{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: button-wall:button:" . 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", $"button-wall:button:{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);