Drive Keyboard Crayons from your own code
Keyboard Crayons makes pictures and signs out of plain keyboard characters (ASCII). The big letters, frames, creatures with a speech bubble, photo-to-text and painting all run free in the browser. The one metered task, sketch, is available over HTTP: you name a thing to draw, up to three more things, a box (columns by rows) and a style, and the model draws a small picture of them inside that box. It returns plain text in labelled sections: the verdict, a title, the picture, a caption, what was left out and a key to the characters.
The division of labour first: the page decides; the model draws. The page works out how many things the box has room for, which ones are drawn and which are left out (and why), whether any words in quotes must stay out of the picture, and the verdict, and sends them as facts. The model never changes those. After the reply, the page checks the picture against the box and lists anything that does not match.
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":"keyboard-crayons"}), 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, subject, with, box, style, facts) 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 sketch 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: sketch (the one task is sketch); a missing or different one is still answered as sketch. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always sketch. |
subject | yes | What to draw, in your own words: one line, up to 80 characters. At least two letters or digits. |
with | yes | Up to three more things to draw (each up to 60 characters), joined by ; , or none. A thing that repeats an earlier one is dropped. |
box | yes | C columns x R rows, such as 40 columns x 12 rows. The page's presets are small (24 x 8), medium (40 x 12) and large (56 x 18); a custom box is 16 to 64 columns and 6 to 24 rows. |
style | yes | line (outlines only) or shaded (areas filled with a light-to-dark ramp). |
facts | yes | The page's binding decisions, one a line: fit (the verdict), draw (the numbered list, most important first), leave out (each with its reason, or nothing), the box, style and characters limits and the caption limit. Build it with TextArt.buildInput. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
What fits, checked in this order: a box up to 8 rows tall fits 1 thing, up to 14 rows 2 things, taller 3; the things past that are left out, the last named first; a drawn thing that contains words in double quotes is drawn without the words, and the words are left out. The verdict is simplified when anything is left out, else as-asked.
The reply
Plain text in labelled sections, in this order, with nothing before or after them (no JSON, so the backslashes and quotes a picture is made of never need escaping):
FIT: as-asked
TITLE: ...
ART:
...the picture, one row a line...
END ART
CAPTION: ...
LEFT OUT:
- none
KEY:
- /\ : ...
| Section | What it holds |
|---|---|
FIT: | as-asked or simplified - always the page's. |
TITLE: | At most 40 characters, naming only the things drawn, in the input's own words. |
ART: … END ART | The picture, one row a line: at most as many rows as the box has rows, no row longer than the box has columns, printable ASCII only (space to ~), no lettering inside the picture. |
CAPTION: | One line, no longer than the box is wide, describing what is drawn. |
LEFT OUT: | One - <the item> - <the reason> line per thing the page left out, in the facts' order and words, or exactly - none. |
KEY: | Two to five - <characters> : <the part they draw> lines; every character named appears in the picture. |
The page reads the reply with SketchKit.parseReply and SketchKit.normalize, then SketchKit.reconcile checks it against the box and lists rows wider or longer than the box (cut at its edge), characters that are not plain ASCII (they become ?), a FIT that differs from the page's, a left-out item the reply does not mention or an extra one it left out on its own, a caption longer than the box is wide, and key characters that are not in the picture.
Worked examples - both verdicts
These are the free samples on the page: the exact input the page sends for each and the sketch that replays for free. Together they cover both verdicts - drawn as asked and simplified to fit.
A lighthouse - as-asked
Input:
{
"task": "sketch",
"subject": "a lighthouse on a rock",
"with": "waves",
"box": "40 columns x 12 rows",
"style": "line",
"facts": "fit: as-asked\ndraw (most important first): 1. a lighthouse on a rock; 2. waves\nleave out: nothing\nbox: at most 40 characters on every art line and at most 12 art lines\nstyle: line drawing - outlines only, with characters such as / \\ | _ - ( ) . ' and spaces inside shapes\ncharacters: printable ASCII only (space to ~), no tabs, no box-drawing or emoji\ncaption: one line, at most 40 characters"
}
Reply (the job's output.output, verbatim):
FIT: as-asked
TITLE: A Lighthouse on a Rock with Waves
ART:
.--.
/ \
| [] |
-+------+-
| |
|------|
| |
| |
/| |\
/_|______|_\
__/\/\__/\/\__/\/\__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
CAPTION: A lighthouse on a rock with waves
LEFT OUT:
- none
KEY:
- .- : lighthouse roof
- /\_ : rock
- |+ : lighthouse tower
- [] : lighthouse light
- ~ : waves
A sleeping cat - simplified
Input:
{
"task": "sketch",
"subject": "a sleeping cat",
"with": "a coffee mug; a pot plant",
"box": "32 columns x 10 rows",
"style": "line",
"facts": "fit: simplified\ndraw (most important first): 1. a sleeping cat; 2. a coffee mug\nleave out: a pot plant - no room in 10 rows (a box this tall fits 2 things)\nbox: at most 32 characters on every art line and at most 10 art lines\nstyle: line drawing - outlines only, with characters such as / \\ | _ - ( ) . ' and spaces inside shapes\ncharacters: printable ASCII only (space to ~), no tabs, no box-drawing or emoji\ncaption: one line, at most 32 characters"
}
Reply (the job's output.output, verbatim):
FIT: simplified
TITLE: A sleeping cat and a coffee mug
ART:
/\_/\
( -.- ) .---.
( ) | |_
( ) | | )
( ,-, ) |___|/
( (_) )
\_____________/
END ART
CAPTION: A sleeping cat and a coffee mug
LEFT OUT:
- a pot plant - no room in 10 rows (a box this tall fits 2 things)
KEY:
- /\_ : the cat's ears
- () : the cat's body
- -. : the cat's eyes
- | : the coffee mug's side
A shaded teapot - as-asked
Input:
{
"task": "sketch",
"subject": "a teapot",
"with": "none",
"box": "24 columns x 8 rows",
"style": "shaded",
"facts": "fit: as-asked\ndraw (most important first): 1. a teapot\nleave out: nothing\nbox: at most 24 characters on every art line and at most 8 art lines\nstyle: shaded - fill areas with a light-to-dark ramp such as . : - = + * # @\ncharacters: printable ASCII only (space to ~), no tabs, no box-drawing or emoji\ncaption: one line, at most 24 characters"
}
Reply (the job's output.output, verbatim):
FIT: as-asked
TITLE: A Teapot
ART:
.-.
.:+++:.
-,(.....)
(:::::::) ,
(=========) |
(#######) '
`-----'
END ART
CAPTION: A teapot
LEFT OUT:
- none
KEY:
- .-:+ : teapot lid
- -, : teapot spout
- (.:=#) : teapot body
- |,' : teapot handle
- `-' : teapot base
A bicycle and a sign - simplified
Input:
{
"task": "sketch",
"subject": "a bicycle",
"with": "a sign saying \"BACK SOON\"",
"box": "56 columns x 18 rows",
"style": "line",
"facts": "fit: simplified\ndraw (most important first): 1. a bicycle; 2. a sign saying \"BACK SOON\" (draw it without the words)\nleave out: the words \"BACK SOON\" - lettering belongs in Big letters, not in a picture\nbox: at most 56 characters on every art line and at most 18 art lines\nstyle: line drawing - outlines only, with characters such as / \\ | _ - ( ) . ' and spaces inside shapes\ncharacters: printable ASCII only (space to ~), no tabs, no box-drawing or emoji\ncaption: one line, at most 56 characters"
}
Reply (the job's output.output, verbatim):
FIT: simplified
TITLE: A Bicycle and a Sign
ART:
.-.
|----------------------o-----o
| / |
| / |
| / |
| / | +-------+
|/ | | |
___ ___ +-------+
/ \ / \ |
| | | | |
| o | | o | |
\ / \ / |
--- --- |
________________________________________________
CAPTION: A bicycle and a sign.
LEFT OUT:
- the words "BACK SOON" - lettering belongs in Big letters, not in a picture
KEY:
- .- : bicycle seat
- |/- : bicycle frame
- o- : bicycle handlebar
- +-| : sign
- _|o\/ : bicycle wheels
Build the input yourself
Load the engine in node (global.window = global; require("./textart.js"); require("./sketchkit.js")), then:
const T = TextArt, S = SketchKit;
const built = T.buildInput(T.form({ subject: "a windmill", with1: "a cow", with2: "", with3: "",
box: "medium", cols: 40, rows: 12, style: "line" })); // built.ok is false, with built.note, until the subject names something
const wire = T.toWire(built.input); // POST this to /estimate and /run; built.analysis holds fit, draw, left, cap, cols, rows
// then: const reply = S.normalize(S.parseReply(job.output.output));
// const notes = S.reconcile(reply, built.analysis); // [{level: "warn" | "ok", text}]
Form keys: subject (up to 80 characters), with1, with2 and with3 (up to 60 each, empty when unused), box (small, medium, large or custom), cols and rows (read only for custom: 16 to 64 and 6 to 24) and style (line or shaded).
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://keyboard-crayons.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://keyboard-crayons.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"]
// keyboard-crayons.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://keyboard-crayons.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://keyboard-crayons.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
}
// KeyboardCrayons.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 KeyboardCrayons {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://keyboard-crayons.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://keyboard-crayons.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://keyboard-crayons.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://keyboard-crayons.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":"keyboard-crayons"}' | 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": "keyboard-crayons"})["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: "keyboard-crayons" })).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": "keyboard-crayons"}, 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", "keyboard-crayons"), 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: "keyboard-crayons" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "keyboard-crayons"])["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 = "keyboard-crayons" }))!["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": "sketch", "subject": "a lighthouse on a rock", "with": "waves", "box": "40 columns x 12 rows", "style": "line", "facts": "fit: as-asked\ndraw (most important first): ..."}
# - 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": "sketch", "subject": "a lighthouse on a rock", "with": "waves", "box": "40 columns x 12 rows", "style": "line", "facts": "fit: as-asked\ndraw (most important first): ..."}
# - 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: "sketch", subject, with, box, style, facts} (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 task and the input: a retry with the same key never bills twice. The page uses keyboard-crayons:<task>:<inputHash>:a<attempt> (the one task is sketch), 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 - the plain-text sections, not JSON; the picture is the lines between ART: and END ART; truncated: true means the balance cut it short.
KEY="keyboard-crayons:sketch:$(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.txt # plain text: FIT, TITLE, ART ... END ART, CAPTION, LEFT OUT, KEY
sed -n '/^ART:/,/^END ART/p' reply.txt # the picture
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "keyboard-crayons:" + 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)
text = j["output"]["output"] # the reply: plain-text sections, not JSON
print(j["charged_credits"], j.get("truncated"))
lines = text.splitlines()
if "ART:" in lines and "END ART" in lines:
print("\n".join(lines[lines.index("ART:") + 1:lines.index("END ART")])) # the picture
else:
print(text)
import { createHash } from "node:crypto";
const key = `keyboard-crayons:${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 text = j.output.output; // the reply: plain-text sections, not JSON
console.log(j.charged_credits, j.truncated);
const art = /^ART:\n([\s\S]*?)\nEND ART/m.exec(text); // the picture
console.log(art ? art[1] : text);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "keyboard-crayons:" + 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 }
}
text := j["output"].(map[string]any)["output"].(string) // the reply: plain-text sections, not JSON
fmt.Println(j["charged_credits"], j["truncated"])
if a := strings.Index(text, "ART:\n"); a >= 0 { // the picture
if b := strings.Index(text[a:], "\nEND ART"); b >= 0 { text = text[a+5 : a+b] }
}
fmt.Println(text)
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "keyboard-crayons:" + 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()));
String text = j.get("output").get("output").asText(); // the reply: plain-text sections, not JSON
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
int a = text.indexOf("ART:\n"), b = a < 0 ? -1 : text.indexOf("\nEND ART", a);
System.out.println(b > a ? text.substring(a + 5, b) : text); // the picture
require "digest"
key = "keyboard-crayons:#{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
text = j["output"]["output"] # the reply: plain-text sections, not JSON
puts [j["charged_credits"], j["truncated"]].inspect
puts text[/^ART:\n(.*?)\nEND ART/m, 1] || text # the picture
$key = "keyboard-crayons:" . $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"]));
$text = $j["output"]["output"]; // the reply: plain-text sections, not JSON
echo $j["charged_credits"], " ", json_encode($j["truncated"] ?? null), "\n";
echo preg_match('/^ART:\n(.*?)\nEND ART/ms', $text, $m) ? $m[1] : $text, "\n"; // the picture
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"keyboard-crayons:{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 text = j["output"]!["output"]!.GetValue<string>(); // the reply: plain-text sections, not JSON
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
var art = System.Text.RegularExpressions.Regex.Match(text, @"^ART:\n(.*?)\nEND ART",
System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.Multiline);
Console.WriteLine(art.Success ? art.Groups[1].Value : text); // the picture
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: keyboard-crayons:sketch:$(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": "sketch", "subject": "a lighthouse on a rock", "with": "waves", "box": "40 columns x 12 rows", "style": "line", "facts": "fit: as-asked\ndraw (most important first): ..."}
# - 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": "keyboard-crayons:sketch:" + 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": `keyboard-crayons:sketch:${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" 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", "keyboard-crayons:sketch:"+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", "keyboard-crayons:sketch:" + 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" => "keyboard-crayons:sketch:#{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: keyboard-crayons:sketch:" . 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", $"keyboard-crayons:sketch:{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);