Drive Here Be Blunders from your own code
The one metered task, map, is available over HTTP. It takes a skill someone is a beginner at, what finished looks like, what they know and have, their hours and weeks, the hazards they picked (what usually makes them quit) and where they are now, and returns a verdict on the goal, a route of quests - each one small real thing to make, with a done test, hours, a cap on watching time, the technique it teaches, the stumble to expect and a beginner question to ask - a rule for each hazard, and what to do in the first hour.
The division of labour first: the page owns the numbers; the model owns the judgement and the words; the page re-checks the reply. The total hours, the island count, the cap on quest 1 and the watching allowance come from mapkit.js, which you can run yourself (see Build the input yourself).
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":"here-be-blunders"}), 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, skill, total_hours, quest_count, hazards) 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: plotting the route 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 and the verdict
Every run sends task: map (plot the route); a missing or different one is still answered as map. The verdict is checked in a fixed order and the first that applies wins:
- rethink - the goal is not one thing a person could make, do or show ("get good at", "not fall behind"), or the skill is a whole field. Exactly 3 quick-try quests, 3
sharper_goals,goal_hoursnull. - first-leg - concrete, but the model's rough estimate
goal_hoursis more thantotal_hours. The route ends on a smallermilestone; 1 to 3next_legs. - reachable - concrete and
goal_hoursis at mosttotal_hours. The route ends on the goal.
The page flags a verdict that disagrees with its own goal_hours.
The run input
Every value is a string (objects are never sent). app.js builds it with MapKit.buildInput(form).input.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always map. |
skill / goal | yes / no | What the person is learning (at most 120 characters) and what finished looks like (400), or not given. |
know / have | no | What they know nearby (600) and the tools or materials they have (400), or nothing listed. |
hours_per_week / weeks / total_hours | yes | 1 to 20 hours a week, 2 to 12 weeks, and the two multiplied. |
quest_count | yes | The island count by the page's rule on the total: up to 3 hours gives 3, up to 8 gives 4, up to 24 gives 6, more gives 8. A rethink always has 3. |
first_cap / watch_rate | yes | The most hours quest 1 may take (2, or the total if smaller) and the watching minutes allowed per quest hour (15). |
hazards | yes | One line per picked hazard: <id> | <name> | <what it looks like>; ids tutorials, perfection, comparing, stuck, time, bored, gear; up to 3, tutorials when none is picked. |
progress | no | Where they are from an earlier route - Landed: ... and Stuck on: ... - note lines (the last 1,500 characters are kept), or starting fresh. |
clip_note | no | What the page shortened, or absent. |
retry_note | no | Sent only on the page's one reformat retry. |
The reply
The reply is one JSON object in job.output.output. The page parses it with MapKit.parseReply and MapKit.normalize (a cut-off stream is closed by MapKit.closeJson and shown as "N of 10 sections recovered").
{"task": "map", "verdict": "reachable | first-leg | rethink", "goal_hours": 20, "title": "...", "summary": "...",
"milestone": "...", "first_hour": "...",
"quests": [{"n": 1, "name": "...", "build": "...", "done_test": "...", "hours": 1.5, "watch_min": 20,
"learns": "...", "stumble": "...", "ask": "...?"}],
"hazards": [{"id": "perfection", "sign": "...", "rule": "..."}],
"next_legs": [], "sharper_goals": []}
| Field | Contract |
|---|---|
quests | quest_count of them (3 for rethink), n 1, 2, 3 in order; hours in half-hour steps, quest 1 at most first_cap, all together at most total_hours; watch_min at most hours x watch_rate; every build makes something, none is "watch" or "read". |
hazards | One per input hazard line, same ids and order: how it shows up for this skill, and a concrete rule. |
milestone | The last island's result: the goal (reachable), a first real result (first-leg) or one of the sharper goals (rethink). |
next_legs / sharper_goals | 1 to 3 for first-leg / exactly 3 for rethink; otherwise empty. |
The page then checks the reply against its own numbers (MapKit.check): the verdict against the hour estimate, the island count, the numbering, hours and half-hour steps, quest 1's cap, the total, every watching cap, passive "watch or read" quests, missing done tests, a rule for every hazard, the next legs and sharper goals by verdict, field names, links, weekdays or clock times in the wording, and - on a re-plot - islands already landed coming back, or quest 1 missing the point you were stuck on. The week-by-week plan and the calendar file are laid out by the page, not the model.
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.
A watercolour teacup in 24 hours - reachable
Input:
{
"task": "map",
"skill": "watercolour painting",
"goal": "paint a postcard-sized picture of my favourite teacup that I'm happy to put on the shelf",
"know": "I doodle in pen a lot and did a little painting at school",
"have": "a cheap set of watercolour paints, two brushes, printer paper, a jar for water",
"hours_per_week": "4",
"weeks": "6",
"total_hours": "24",
"quest_count": "6",
"first_cap": "2",
"watch_rate": "15",
"hazards": "perfection | Perfection Reef | redoing the first thing until it is perfect and never moving on\ncomparing | Comparison Cliffs | measuring your first tries against experts' best work",
"progress": "starting fresh"
}
Reply (the first three quests shown):
{
"task": "map",
"verdict": "reachable",
"goal_hours": 18,
"title": "A Postcard Teacup You're Happy To Shelve",
"summary": "You have 4 hours a week for 6 weeks, 24 hours in total. Painting a postcard-sized teacup you're happy to shelve looks reachable in that time, with a rough estimate of about 18 beginner hours needed to get there.",
"milestone": "a postcard-sized teacup painting you're happy to shelve",
"first_hour": "Today, pour water into your jar, cut your printer paper into a few smaller pieces, and paint one complete, thumbnail-sized picture of your teacup using whichever colors in your set feel closest, without stopping to fix anything.",
"quests": [
{
"n": 1,
"name": "Teacup First Splash",
"build": "Paint a complete, thumbnail-sized picture of your teacup in one sitting, using whichever colors in your set feel closest, without stopping partway to perfect any one part.",
"done_test": "You have one finished thumbnail-sized teacup painting, start to finish, even though it is rough.",
"hours": 1.5,
"watch_min": 10,
"learns": "finishing something small start to finish",
"stumble": "Beginners stop partway through this first try to make it look right and never finish it; keep going at speed until the picture is complete, mistakes and all.",
"ask": "When a first quick painting feels off, how do more experienced painters tell what to fix first?"
},
{
"n": 2,
"name": "Watery Puddle Point",
"build": "Paint a row of five color blocks on a spare piece of your printer paper, starting with paint straight from your set and thinning the same puddle with a little more water each time.",
"done_test": "You can line the five blocks up and see each one get lighter than the last in an even step.",
"hours": 1.5,
"watch_min": 10,
"learns": "controlling paint to water ratio",
"stumble": "Most beginners mix a fresh batch of paint for each block so the lightness jumps around unevenly; keep thinning the same puddle instead of mixing new paint each time.",
"ask": "How wet should my brush feel before I know it has too much water for a clean line?"
},
{
"n": 3,
"name": "Teacup Hue Harbor",
"build": "Mix two colors from your set at a time and paint three small circles on a spare piece of paper, aiming to match your favourite teacup's color as closely as you can.",
"done_test": "You can hold the painted paper against the teacup and point to the circle that sits closest to its color.",
"hours": 1.5,
"watch_min": 10,
"learns": "matching a real color by mixing",
"stumble": "Beginners often add a third or fourth color while chasing the exact shade and end up with mud; stop at two colors and adjust the ratio between them instead.",
"ask": "If my mix is close but not exact, is it better to keep adjusting or paint with the closest match I already have?"
},
"... 3 more quests, same shape"
],
"hazards": [
{
"id": "perfection",
"sign": "With watercolour, this often shows up as repainting the same wash on your teacup again and again until the paper goes soft and the color turns muddy.",
"rule": "Give any one area at most two tries in a sitting, then stop and only go back to it once it is fully dry."
},
{
"id": "comparing",
"sign": "This shows up as holding your loose, beginner painting next to a glossy, detailed teacup painting you find online and deciding yours is no good.",
"rule": "Only compare today's painting to the one you made in an earlier quest of this map, never to a finished painting made by someone else."
}
],
"next_legs": [],
"sharper_goals": []
}
A piano piece in 8 hours, re-plotted after getting stuck - first leg
Input:
{
"task": "map",
"skill": "piano",
"goal": "play a slow four-minute classical piece from sheet music, start to finish",
"know": "nothing - I have never read music",
"have": "a 61-key keyboard and headphones",
"hours_per_week": "2",
"weeks": "4",
"total_hours": "8",
"quest_count": "4",
"first_cap": "2",
"watch_rate": "15",
"hazards": "tutorials | Tutorial Swamp | watching or reading about it instead of making something\nstuck | Fog of Errors | hitting a mistake you cannot explain and stopping there\ntime | Busy Straits | the week filling up before you sit down to practise",
"progress": "Landed: Five-Finger Harbour (Play the five notes up from middle C with your right hand, then back down, without looking at the keys)\nStuck on: Bass Clef Shoals (Name and play eight bass-clef notes from a printed page) - I have to count the lines every single time"
}
Reply (the first three quests shown):
{
"task": "map",
"verdict": "first-leg",
"goal_hours": 30,
"title": "Two Hands, One Short Tune, No Stopping",
"summary": "At 2 hours a week for 4 weeks, this map covers 8 hours in total. Playing a full four-minute classical piece from the page in one slow run is a bigger target, a rough estimate of 30 beginner hours in total, so this map is a first leg toward it, ending with a shorter two-hand tune you can play straight through.",
"milestone": "play a short two-hand tune straight through from the page",
"first_hour": "Today, sit at your keyboard with headphones on and write three bass-clef landmark notes onto a few scraps of paper. Check each note against the keys until you can name it without counting up from the bottom line every time.",
"quests": [
{
"n": 1,
"name": "Landmark Ledge",
"build": "Make ten paper flashcards of three bass-clef landmark notes, one each for the bottom line, the middle line, and the top line, and play each note on the keyboard the instant you flip a card.",
"done_test": "You can look at any of the ten cards and play the right note within two seconds, three times in a row without a mistake.",
"hours": 1.5,
"watch_min": 10,
"learns": "reading bass clef by landmarks",
"stumble": "The common mistake is counting up from the bottom line every time; fix it by picking one landmark note per hand position and checking distance from there instead.",
"ask": "Is there a faster way to recognise notes than counting lines, once the landmarks are solid?"
},
{
"n": 2,
"name": "Two-Hand Cove",
"build": "Write out a four-bar line using the five right-hand notes you already know and the three bass-clef landmarks from before, then play it hands together, slowly, counting each beat out loud.",
"done_test": "You can play all four bars hands together twice in a row without stopping or looking away from the page.",
"hours": 2,
"watch_min": 10,
"learns": "playing both hands together slowly",
"stumble": "The common mistake is letting the right hand race ahead of the left; fix it by counting out loud on every beat until both hands land together.",
"ask": "What is a good way to practise the hands separately before putting them together?"
},
{
"n": 3,
"name": "Eight-Bar Inlet",
"build": "Pick a short eight-bar classical-sounding line written in both clefs and learn it phrase by phrase, two bars at a time, until you can play it slowly all the way through.",
"done_test": "You can play the full eight bars from the page at a slow steady beat without stopping, twice in a row.",
"hours": 2.5,
"watch_min": 15,
"learns": "learning a piece phrase by phrase",
"stumble": "The common mistake is trying to play all eight bars at once from the start; fix it by mastering two bars at a time before joining them.",
"ask": "How slow is too slow when first learning a new line?"
},
"... 1 more quests, same shape"
],
"hazards": [
{
"id": "tutorials",
"sign": "Tutorial Swamp shows up here as spending your two hours a week watching videos about reading bass clef instead of sitting down and naming notes on the keyboard yourself.",
"rule": "After five minutes of any video or article about a note or a piece, stop it and spend the rest of that time playing, not watching."
},
{
"id": "stuck",
"sign": "Fog of Errors shows up here as hitting a note you cannot name or a hand position that will not line up, and stopping the whole practice session because of it.",
"rule": "When a mistake takes more than 2 minutes to puzzle out alone, mark the spot, skip it for now, and keep playing the rest of the line."
},
{
"id": "time",
"sign": "Busy Straits shows up here as the week passing without the two short practice sessions getting scheduled, so the keyboard stays closed.",
"rule": "Before the week starts, put both practice sessions on your schedule, each at least 20 minutes long."
}
],
"next_legs": [
"Learn a full one-page piece in both clefs, hands together, at a slow steady beat.",
"Practise naming notes in both clefs quickly, without counting up from the bottom every time.",
"Learn the chosen four-minute piece phrase by phrase, slowly, until it runs start to finish."
],
"sharper_goals": []
}
"Get good at AI" in 3 hours - rethink
Input:
{
"task": "map",
"skill": "AI",
"goal": "get good at AI so I don't fall behind",
"know": "I have used a chatbot a few times",
"have": "a laptop",
"hours_per_week": "1",
"weeks": "3",
"total_hours": "3",
"quest_count": "3",
"first_cap": "2",
"watch_rate": "15",
"hazards": "bored | The Doldrums | losing interest once practice gets repetitive\ngear | Kit Bazaar | shopping for tools and supplies instead of starting with what you have",
"progress": "starting fresh"
}
Reply (the first three quests shown):
{
"task": "map",
"verdict": "rethink",
"goal_hours": null,
"title": "Three Quick Tries Before Charting AI",
"summary": "At 1 hour a week for 3 weeks this map holds 3 hours total. AI is too wide a target to aim at directly, so this map holds three quick tries to find a sharper goal first.",
"milestone": "choose one sharper AI goal to chart next",
"first_hour": "Open a chatbot on your laptop today and ask it to help you write a short plan for one real task you want done this week, then save what it gives you.",
"quests": [
{
"n": 1,
"name": "Chatbot Cove",
"build": "Open a chatbot on your laptop and ask it to help you write a short plan for one real task you have this week, then save the finished plan.",
"done_test": "You can read back the saved plan and check it covers every step for your task.",
"hours": 1,
"watch_min": 10,
"learns": "giving a chatbot a clear request",
"stumble": "Beginners often ask something too broad and get a vague answer, so ask for one specific real task instead.",
"ask": "What makes a request to a chatbot specific enough to get a useful answer?"
},
{
"n": 2,
"name": "Picture Shoal",
"build": "Open a free image-making AI tool in your browser and type a short description of a scene you choose, then save the picture it makes.",
"done_test": "You can open the saved picture file and see it matches the scene you described.",
"hours": 1,
"watch_min": 10,
"learns": "describing a scene in words",
"stumble": "Beginners write one vague word and get a strange picture, so add three or four concrete details instead.",
"ask": "How much detail does a description need before the picture matches what you imagined?"
},
{
"n": 3,
"name": "Sorting Reef",
"build": "Give a chatbot a messy list of at least ten items from your own life and ask it to sort them into clear groups, then check each grouping by hand.",
"done_test": "You can point to each item and say out loud why it sits in its group, with no group left unchecked.",
"hours": 1,
"watch_min": 10,
"learns": "checking an AI's sorting by hand",
"stumble": "Beginners accept the groups without checking and miss wrong ones, so check every single item once before trusting it.",
"ask": "How do you tell when an AI's sorting is wrong versus just different from what you expected?"
}
],
"hazards": [
{
"id": "bored",
"sign": "Early quests with AI can feel repetitive once you have typed a few requests and seen similar answers come back.",
"rule": "Watch your laptop's clock and give each quest 15 minutes, stopping as soon as you have one finished thing to show."
},
{
"id": "gear",
"sign": "It is tempting to hunt for a fancier AI tool or paid service before you have tried the free one in front of you.",
"rule": "Use only your laptop and one free tool for all 3 quests before you look for anything else."
}
],
"next_legs": [],
"sharper_goals": [
"Make a short illustrated story, a few paragraphs written with a chatbot's help and one picture made by an AI tool.",
"Build a sorted shopping or packing list for a real trip, using a chatbot to group and check it.",
"Write a one-page explainer on a topic you pick, using a chatbot for a first draft you then rewrite in your own words."
]
}
Build the input yourself
Load the script in node (global.window = global; require("./mapkit.js")), then:
const K = MapKit;
const form = K.form({ skill: "ukulele", goal: "play three chords cleanly through one simple song", have: "a ukulele",
hours: 2, weeks: 5, quit: ["tutorials", "bored"] });
const fig = K.figures(form); // total hours, island count, quest 1 cap, watching allowance
const built = K.buildInput(form);
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 reply
// K.ics(built, reply) // the route as one calendar event per week
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://here-be-blunders.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://here-be-blunders.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"]
// here-be-blunders.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://here-be-blunders.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://here-be-blunders.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
}
// HereBeBlunders.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 HereBeBlunders {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://here-be-blunders.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://here-be-blunders.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://here-be-blunders.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://here-be-blunders.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":"here-be-blunders"}' | 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": "here-be-blunders"})["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: "here-be-blunders" })).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": "here-be-blunders"}, 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", "here-be-blunders"), 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: "here-be-blunders" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "here-be-blunders"])["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 = "here-be-blunders" }))!["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": "map", "skill": "watercolour painting", "goal": "paint a postcard-sized picture of my favourite teacup that I'm happy to put on the shelf", "know": "I doodle in pen a lot and did a little painting at school", "have": "a cheap set of watercolour paints, two brushes, printer paper, a jar for water", "hours_per_week": "4", "weeks": "6", "total_hours": "24", "quest_count": "6", "first_cap": "2", "watch_rate": "15", "hazards": "perfection | Perfection Reef | redoing the first thing until it is perfect and never moving on\ncomparing | Comparison Cliffs | measuring your first tries against experts' best work", "progress": "starting fresh"}
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": "map", "skill": "watercolour painting", "goal": "paint a postcard-sized picture of my favourite teacup that I'm happy to put on the shelf", "know": "I doodle in pen a lot and did a little painting at school", "have": "a cheap set of watercolour paints, two brushes, printer paper, a jar for water", "hours_per_week": "4", "weeks": "6", "total_hours": "24", "quest_count": "6", "first_cap": "2", "watch_rate": "15", "hazards": "perfection | Perfection Reef | redoing the first thing until it is perfect and never moving on\ncomparing | Comparison Cliffs | measuring your first tries against experts' best work", "progress": "starting fresh"}
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: "map", skill, goal, know, have, hours_per_week, weeks, total_hours, quest_count, first_cap, watch_rate, hazards, progress, clip_note} (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 here-be-blunders:<task>:<inputHash>:a<attempt> (the one task is map), 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="here-be-blunders:map:$(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 = "here-be-blunders:" + 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 = `here-be-blunders:${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 := "here-be-blunders:" + 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 = "here-be-blunders:" + 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 = "here-be-blunders:#{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 = "here-be-blunders:" . $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 = $"here-be-blunders:{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: here-be-blunders:map:$(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": "map", "skill": "watercolour painting", "goal": "paint a postcard-sized picture of my favourite teacup that I'm happy to put on the shelf", "know": "I doodle in pen a lot and did a little painting at school", "have": "a cheap set of watercolour paints, two brushes, printer paper, a jar for water", "hours_per_week": "4", "weeks": "6", "total_hours": "24", "quest_count": "6", "first_cap": "2", "watch_rate": "15", "hazards": "perfection | Perfection Reef | redoing the first thing until it is perfect and never moving on\ncomparing | Comparison Cliffs | measuring your first tries against experts' best work", "progress": "starting fresh"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "here-be-blunders:map:" + 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": `here-be-blunders:map:${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", "here-be-blunders:map:"+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", "here-be-blunders:map:" + 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" => "here-be-blunders:map:#{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: here-be-blunders:map:" . 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", $"here-be-blunders:map:{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);