← Keyboard Crayons
API

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, subject, with, box, style, facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the sketch is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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.

FieldRequiredWhat it holds
taskyesAlways sketch.
subjectyesWhat to draw, in your own words: one line, up to 80 characters. At least two letters or digits.
withyesUp to three more things to draw (each up to 60 characters), joined by ; , or none. A thing that repeats an earlier one is dropped.
boxyesC 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.
styleyesline (outlines only) or shaded (areas filled with a light-to-dark ramp).
factsyesThe 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_notenoOnly 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:
- /\ : ...
SectionWhat 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 ARTThe 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"}
}

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')

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}' 

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}' 

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}' 

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