Drive Tell Me When from your own code
The one metered task, talk, is available over HTTP. It takes written instructions you have to read out to someone doing the job on the other end of a phone call - already split into numbered written steps, each with its word count and what trips the ear - plus the page's numbers. It returns a talk-through: the verdict, one line for you before you ring, what the listener should have within reach, an opening, short parts that each pair one thing to say with one check-in question, where to break for a second call, a closing question and one tip.
The division of labour first: the page counts; the model words it. The page splits the text into written steps, times each one at the chosen pace (slow 110, steady 140 or brisk 170 words a minute), marks what trips the ear (pointing at a picture, numbers and codes, symbols, abbreviations, asides in brackets, long sentences, several actions at once), works out the verdict, the break for two calls, how many parts to write, what the tip is about and whether safety applies, and sends them as facts. The model never changes those. After the reply, the page lists anything that does not match. A reading aid, not professional advice.
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":"tell-me-when"}), 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, job, steps, 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 talk-through 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: talk (the one task is talk); a missing or different one is still answered as talk. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always talk. |
job | yes | what the job is | listener: ... | pace: steady (140 words a minute). The job or the listener is not named when empty; a job is up to 70 characters, a listener up to 40; the pace is slow (110), steady (140) or brisk (170). |
steps | yes | One written step a line, in order: N. the step's text | W words | trips the ear: ... - the kinds of trouble with the exact words that cause them, or none. Two to twenty steps; a step is up to 300 characters. |
facts | yes | The page's numbers and choices: written steps, reading time at the pace, the steps that point at a picture, the steps that trip the ear (X of N), the verdict, the break, how many parts to write (a minimum and a maximum), what the tip is about, the safety line and the listener. Build it with TalkKit.buildInput. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
The verdict, checked in this order: send-a-picture when a step points at a picture, diagram or figure the listener cannot see; else two-calls when there are 13 written steps or more; else slow-it-down when a third or more of the steps trip the ear; else read-it-out.
The reply
One JSON object, no prose around it:
{"task": "talk", "verdict": "read-it-out", "before_you_call": "...", "have_ready": ["..."], "opening": "...", "parts": [{"steps": [1], "say": "...", "check": "...?"}], "pause_after": 0, "pause_line": "", "closing": "...?", "tip": "..."}
| Field | What it holds |
|---|---|
verdict | send-a-picture, two-calls, slow-it-down or read-it-out - always the page's. |
before_you_call | One sentence for you, the caller, about what to do before ringing: the picture to send (by step number), the two calls and where the first ends, how many steps trip the ear (X of N), or that the steps read well aloud. |
have_ready | A list of 0 to 6 short noun phrases, in the steps' own words, for the listener to have within reach. Empty when the steps name nothing to have ready. |
opening | One or two sentences to the listener: the job in the input's own words, how many parts (a numeral equal to the length of parts), the things to get first, the break for two calls, and - when the page flags safety - a request to stop and say if anything seems wrong. |
parts | Between the page's minimum and maximum, in the order of the written steps, each {"steps": [int], "say": "...", "check": "...?"}. steps names the written steps it comes from; every step appears at least once. say is at most 30 words and two sentences, one action each, keeping every number as the step writes it, abbreviations written out and symbols said as words. check is one question of 14 words or fewer that the listener answers before you go on. |
pause_after | For two-calls: the number (from 1) of the last part that includes the break step. Otherwise 0. |
pause_line | For two-calls: one sentence that ends the first call and says you will ring back, without a day or time. Otherwise empty. |
closing | One question asking the listener to describe how it looks now - never a claim that it worked. |
tip | One sentence for you about what the page says the tip is about (numbers and codes, symbols, abbreviations, an aside, a long sentence, several actions at once, or nothing), naming that step by its number. |
The page reads the reply with ReplyKit.parseReply and ReplyKit.normalize, then re-checks it against the written steps and lists, under "The page's check", a verdict that differs from the page's, a step left out or covered out of order, a part count outside the range, a number dropped or added, a part that points at a page, a check that is not a question, a wrong break and a missing safety line.
Worked examples - every verdict
These are the free samples on the page: the exact input the page sends for each and the talk-through that replays for free. Together they cover every verdict - send a picture first, two calls, slow it down and read it out.
Tying a bow tie - send-a-picture
Input:
{
"task": "talk",
"job": "Tying a bow tie | listener: my brother | pace: steady (140 words a minute)",
"steps": "1. Hang the tie around your neck so the left end hangs about 3 cm lower than the right. | 18 words | trips the ear: abbreviations (cm)\n2. Cross the longer end over the shorter end, close to your neck. | 12 words | trips the ear: none\n3. Bring the longer end up through the loop at your neck and let it hang down the front. | 18 words | trips the ear: none\n4. Fold the shorter end into a bow shape, as shown in the diagram, and hold it across your collar. | 19 words | trips the ear: points at a picture (as shown)\n5. Drop the longer end down over the front of the bow. | 11 words | trips the ear: none\n6. Fold the longer end into a second bow shape and push it through the loop behind the front bow. | 19 words | trips the ear: none\n7. Pull both folded loops gently to tighten, then straighten the bow. | 11 words | trips the ear: none",
"facts": "Written steps: 7.\nReading it as written: 108 words, about 46 s at 140 words a minute, not counting the doing.\nSteps that point at a picture the listener cannot see: step 4.\nSteps that trip the ear: 2 of 7 - steps 1 and 4.\nVerdict: send-a-picture (checked in order: a step points at a picture is send-a-picture; else 13 steps or more is two-calls; else a third or more of the steps tripping the ear is slow-it-down; else read-it-out)\nBreak: none - one call.\nParts: write between 7 and 14 parts.\nTip about: abbreviations at step 1 (cm).\nSafety: nothing flagged.\nListener: my brother"
}
Reply:
{
"task": "talk",
"verdict": "send-a-picture",
"before_you_call": "Before you call, send your brother the picture for step 4, showing a bow shape.",
"have_ready": [
"tie"
],
"opening": "We're tying a bow tie, in 11 parts. Can you get your tie?",
"parts": [
{
"steps": [
1
],
"say": "Hang the tie around your neck so the left end hangs about 3 centimeters lower than the right.",
"check": "Is the left end hanging about 3 centimeters lower than the right?"
},
{
"steps": [
2
],
"say": "Cross the longer end over the shorter end, close to your neck.",
"check": "Have you crossed the longer end over the shorter end?"
},
{
"steps": [
3
],
"say": "Bring the longer end up through the loop at your neck.",
"check": "Is the longer end through the loop at your neck?"
},
{
"steps": [
3
],
"say": "Let it hang down the front.",
"check": "Is it hanging down the front?"
},
{
"steps": [
4
],
"say": "Fold the shorter end into a bow shape, like the picture I sent you.",
"check": "Does it look like the picture I sent you?"
},
{
"steps": [
4
],
"say": "Hold it across your collar.",
"check": "Is it across your collar?"
},
{
"steps": [
5
],
"say": "Drop the longer end down over the front of the bow.",
"check": "Is the longer end hanging over the front of the bow?"
},
{
"steps": [
6
],
"say": "Fold the longer end into a second bow shape.",
"check": "Is it folded into a second bow shape?"
},
{
"steps": [
6
],
"say": "Push it through the loop behind the front bow.",
"check": "Is it through the loop behind the front bow?"
},
{
"steps": [
7
],
"say": "Pull both folded loops gently to tighten.",
"check": "Are both folded loops tight now?"
},
{
"steps": [
7
],
"say": "Straighten the bow.",
"check": "Is the bow straight now?"
}
],
"pause_after": 0,
"pause_line": "",
"closing": "What does it look like now?",
"tip": "At step 1, say 'cm' as 'centimeters.'"
}
Setting up a board game - two-calls
Input:
{
"task": "talk",
"job": "Setting up the board game | listener: Sam | pace: brisk (170 words a minute)",
"steps": "1. Unfold the board in the middle of the table. | 9 words | trips the ear: none\n2. Give each player a screen and 5 coins. | 8 words | trips the ear: none\n3. Shuffle the green cards and put them face down by the board. | 12 words | trips the ear: none\n4. Shuffle the orange cards and put them face down next to the green ones. | 14 words | trips the ear: none\n5. Deal 3 green cards to each player. | 7 words | trips the ear: none\n6. Put the black pawn on the start square. | 8 words | trips the ear: none\n7. Each player picks a colour and takes the 4 pawns of that colour. | 13 words | trips the ear: none\n8. Put one of your pawns on the score track at zero. | 11 words | trips the ear: none\n9. Stack the gold tokens next to the bag. | 8 words | trips the ear: none\n10. Put the 12 crystals in the bag and shake it. | 10 words | trips the ear: none\n11. Draw 2 crystals from the bag and put them on the harbour spaces. | 13 words | trips the ear: none\n12. Turn the timer over so the sand is at the bottom. | 11 words | trips the ear: none\n13. The youngest player takes the first-player marker. | 7 words | trips the ear: none\n14. Leave the rule book open at the page with the round summary. | 12 words | trips the ear: none",
"facts": "Written steps: 14.\nReading it as written: 143 words, about 50 s at 170 words a minute, not counting the doing.\nSteps that point at a picture the listener cannot see: none.\nSteps that trip the ear: 0 of 14.\nVerdict: two-calls (checked in order: a step points at a picture is send-a-picture; else 13 steps or more is two-calls; else a third or more of the steps tripping the ear is slow-it-down; else read-it-out)\nBreak: the first call ends after step 7; the second call starts at step 8.\nParts: write between 14 and 24 parts.\nTip about: nothing on the page trips the ear - waiting for each answer before going on.\nSafety: nothing flagged.\nListener: Sam"
}
Reply:
{
"task": "talk",
"verdict": "two-calls",
"before_you_call": "There are 14 written steps, so plan two calls, with the first call ending after step 7.",
"have_ready": [
"board",
"screen",
"coins",
"green cards",
"orange cards",
"black pawn",
"pawns",
"gold tokens"
],
"opening": "We're setting up the board game, so get the board, a screen, coins, green cards, orange cards, the black pawn, pawns and the gold tokens ready first. There are 19 parts, and we'll stop for a break after part 10.",
"parts": [
{
"steps": [
1
],
"say": "Unfold the board in the middle of the table.",
"check": "Is the board unfolded?"
},
{
"steps": [
2
],
"say": "Give each player a screen and 5 coins.",
"check": "Does each player have a screen and 5 coins?"
},
{
"steps": [
3
],
"say": "Shuffle the green cards.",
"check": "Are the green cards shuffled?"
},
{
"steps": [
3
],
"say": "Put them face down by the board.",
"check": "Are the cards face down by the board?"
},
{
"steps": [
4
],
"say": "Shuffle the orange cards.",
"check": "Are the orange cards shuffled?"
},
{
"steps": [
4
],
"say": "Put them face down next to the green ones.",
"check": "Are the orange cards face down next to the green ones?"
},
{
"steps": [
5
],
"say": "Deal 3 green cards to each player.",
"check": "Does each player have 3 green cards?"
},
{
"steps": [
6
],
"say": "Put the black pawn on the start square.",
"check": "Is the black pawn on the start square?"
},
{
"steps": [
7
],
"say": "Each player picks a colour.",
"check": "Has each player picked a colour?"
},
{
"steps": [
7
],
"say": "Each player takes the 4 pawns of that colour.",
"check": "Does each player have the 4 pawns of that colour?"
},
{
"steps": [
8
],
"say": "Put one of your pawns on the score track at zero.",
"check": "Is one of your pawns on the score track at zero?"
},
{
"steps": [
9
],
"say": "Stack the gold tokens next to the bag.",
"check": "Are the gold tokens stacked next to the bag?"
},
{
"steps": [
10
],
"say": "Put the 12 crystals in the bag.",
"check": "Are the 12 crystals in the bag?"
},
{
"steps": [
10
],
"say": "Shake it.",
"check": "Have you shaken the bag?"
},
{
"steps": [
11
],
"say": "Draw 2 crystals from the bag.",
"check": "Have you drawn 2 crystals from the bag?"
},
{
"steps": [
11
],
"say": "Put them on the harbour spaces.",
"check": "Are the crystals on the harbour spaces?"
},
{
"steps": [
12
],
"say": "Turn the timer over so the sand is at the bottom.",
"check": "Is the sand at the bottom?"
},
{
"steps": [
13
],
"say": "The youngest player takes the first-player marker.",
"check": "Does the youngest player have the first-player marker?"
},
{
"steps": [
14
],
"say": "Leave the rule book open at the page with the round summary.",
"check": "Is the rule book open at the page with the round summary?"
}
],
"pause_after": 10,
"pause_line": "I'll stop here and call you back to finish.",
"closing": "Can you describe what the table looks like now?",
"tip": "Nothing here is tricky to hear, so wait for Sam's answer after each part before you go on."
}
Pancake batter - slow-it-down
Input:
{
"task": "talk",
"job": "Making the pancake batter | listener: Dad | pace: slow (110 words a minute)",
"steps": "1. Sift 125g plain flour + 1/4 tsp salt into a large bowl (the big glass one). | 16 words | trips the ear: numbers and codes (125g, 1/4); symbols (+, 1/4); abbreviations (tsp); an aside in brackets (the big glass one)\n2. Make a well in the middle & crack in 2 eggs. | 11 words | trips the ear: symbols (&)\n3. Pour in about 300ml milk a bit at a time, whisking all the while, then beat for approx. 2 mins until smooth; no lumps. | 24 words | trips the ear: numbers and codes (300ml); abbreviations (approx, mins); several actions at once (3 actions)\n4. Leave the batter to rest for 30 mins. | 8 words | trips the ear: abbreviations (mins)\n5. Heat 1 tsp oil in the frying pan on a medium heat. | 12 words | trips the ear: abbreviations (tsp)\n6. Pour in 1/2 a ladle of batter and tilt the pan so it covers the base. | 16 words | trips the ear: numbers and codes (1/2); symbols (1/2)\n7. Cook for 1 min, flip with the spatula & cook the other side for 30 secs. | 16 words | trips the ear: symbols (&); abbreviations (min, secs)",
"facts": "Written steps: 7.\nReading it as written: 103 words, about 56 s at 110 words a minute, not counting the doing.\nSteps that point at a picture the listener cannot see: none.\nSteps that trip the ear: 7 of 7 - steps 1, 2, 3, 4, 5, 6 and 7.\nVerdict: slow-it-down (checked in order: a step points at a picture is send-a-picture; else 13 steps or more is two-calls; else a third or more of the steps tripping the ear is slow-it-down; else read-it-out)\nBreak: none - one call.\nParts: write between 7 and 14 parts.\nTip about: numbers and codes at step 1 (125g, 1/4).\nSafety: the steps mention \"heat\" and \"frying pan\" - keep every warning the steps give, and the opening asks them to stop and say if anything seems wrong.\nListener: Dad"
}
Reply:
{
"task": "talk",
"verdict": "slow-it-down",
"before_you_call": "7 of 7 written steps are tricky to read aloud, so you will take them one small part at a time.",
"have_ready": [
"plain flour",
"salt",
"eggs",
"milk",
"oil",
"frying pan",
"ladle",
"spatula"
],
"opening": "We're making the pancake batter, in 12 parts, so can you get the plain flour, salt, eggs, milk, oil, frying pan, ladle and spatula ready first? Please stop and tell me if anything seems wrong.",
"parts": [
{
"steps": [
1
],
"say": "Sift 125 grams of plain flour and 1/4 teaspoon of salt into a large bowl. It's the big glass one.",
"check": "Have you sifted the flour and salt into the bowl?"
},
{
"steps": [
2
],
"say": "Make a well in the middle.",
"check": "Have you made a well in the middle?"
},
{
"steps": [
2
],
"say": "Crack in 2 eggs.",
"check": "Are the 2 eggs cracked in?"
},
{
"steps": [
3
],
"say": "Pour in about 300 millilitres of milk a bit at a time. Whisk all the while.",
"check": "Have you poured in the milk while whisking?"
},
{
"steps": [
3
],
"say": "Beat for approximately 2 minutes until smooth, with no lumps.",
"check": "Is it smooth with no lumps?"
},
{
"steps": [
4
],
"say": "Leave the batter to rest for 30 minutes.",
"check": "Have you set the batter to rest?"
},
{
"steps": [
5
],
"say": "Heat 1 teaspoon of oil in the frying pan on a medium heat.",
"check": "Has the oil heated in the pan?"
},
{
"steps": [
6
],
"say": "Pour in 1/2 a ladle of batter.",
"check": "Have you poured in the ladle of batter?"
},
{
"steps": [
6
],
"say": "Tilt the pan so it covers the base.",
"check": "Does it cover the base?"
},
{
"steps": [
7
],
"say": "Cook for 1 minute.",
"check": "Is it cooking?"
},
{
"steps": [
7
],
"say": "Flip it with the spatula.",
"check": "Have you flipped it?"
},
{
"steps": [
7
],
"say": "Cook the other side for 30 seconds.",
"check": "Is the other side cooking?"
}
],
"pause_after": 0,
"pause_line": "",
"closing": "How does it look now?",
"tip": "For step 1, say the numbers slowly, then say them again, and let Dad repeat them back."
}
A new vacuum bag - read-it-out
Input:
{
"task": "talk",
"job": "Changing the vacuum cleaner bag | listener: Grandad | pace: steady (140 words a minute)",
"steps": "1. Unplug the vacuum cleaner. | 4 words | trips the ear: none\n2. Press the grey button on top to open the front cover. | 11 words | trips the ear: none\n3. Lift out the full bag by its cardboard tab. | 9 words | trips the ear: none\n4. Drop the old bag straight into the bin. | 8 words | trips the ear: none\n5. Slide the new bag's cardboard tab into the holder until it clicks. | 12 words | trips the ear: none\n6. Close the cover and press it until it snaps shut. | 10 words | trips the ear: none",
"facts": "Written steps: 6.\nReading it as written: 54 words, about 23 s at 140 words a minute, not counting the doing.\nSteps that point at a picture the listener cannot see: none.\nSteps that trip the ear: 0 of 6.\nVerdict: read-it-out (checked in order: a step points at a picture is send-a-picture; else 13 steps or more is two-calls; else a third or more of the steps tripping the ear is slow-it-down; else read-it-out)\nBreak: none - one call.\nParts: write between 6 and 12 parts.\nTip about: nothing on the page trips the ear - waiting for each answer before going on.\nSafety: nothing flagged.\nListener: Grandad"
}
Reply:
{
"task": "talk",
"verdict": "read-it-out",
"before_you_call": "The steps read well aloud, so once you're on the phone with Grandad, the work is mostly waiting for each answer.",
"have_ready": [
"new bag"
],
"opening": "We're changing the vacuum cleaner bag, and I'll walk you through it in 7 parts. Can you get the new bag ready first?",
"parts": [
{
"steps": [
1
],
"say": "Unplug the vacuum cleaner.",
"check": "Is the vacuum cleaner unplugged?"
},
{
"steps": [
2
],
"say": "Press the grey button on top to open the front cover.",
"check": "Is the front cover open?"
},
{
"steps": [
3
],
"say": "Lift out the full bag by its cardboard tab.",
"check": "Is the full bag out?"
},
{
"steps": [
4
],
"say": "Drop the old bag straight into the bin.",
"check": "Is the old bag in the bin?"
},
{
"steps": [
5
],
"say": "Slide the new bag's cardboard tab into the holder until it clicks.",
"check": "Did you hear it click?"
},
{
"steps": [
6
],
"say": "Close the cover.",
"check": "Is the cover closed?"
},
{
"steps": [
6
],
"say": "Press it until it snaps shut.",
"check": "Did it snap shut?"
}
],
"pause_after": 0,
"pause_line": "",
"closing": "What does the vacuum cleaner look like now?",
"tip": "Just wait for Grandad to answer before you move on each time."
}
Build the input yourself
Load the engine in node (global.window = global; require("./talkkit.js"); require("./replykit.js")), then:
const K = TalkKit, R = ReplyKit;
const built = K.buildInput(K.form({ job: "Changing a light bulb", who: "Mum", pace: "steady",
text: "Switch the lamp off at the wall.\nUnscrew the old bulb.\nScrew in the new bulb until it stops." })); // built.ok is false, with built.note, until there are two steps
const wire = K.toWire(built.input); // POST this to /estimate and /run; built.analysis holds rows, verdict, breakAfter, minParts, maxParts, tip, hazards
// then: const reply = R.normalize(R.parseReply(job.output.output));
Form keys: job (up to 70 characters), who (the listener, up to 40), pace (slow, steady or brisk) and text (the pasted instructions, one step a line, up to 4,000 characters). Leading markers such as 1., Step 2: or - are dropped; a single paragraph is split into sentences; only the first 20 steps are read.
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://tell-me-when.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://tell-me-when.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"]
// tell-me-when.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://tell-me-when.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://tell-me-when.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
}
// TellMeWhen.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 TellMeWhen {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://tell-me-when.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://tell-me-when.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://tell-me-when.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://tell-me-when.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":"tell-me-when"}' | 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": "tell-me-when"})["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: "tell-me-when" })).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": "tell-me-when"}, 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", "tell-me-when"), 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: "tell-me-when" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "tell-me-when"])["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 = "tell-me-when" }))!["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": "talk", "job": "Changing the vacuum cleaner bag | listener: Grandad | pace: steady (140 words a minute)", "steps": "1. Unplug the vacuum cleaner. | 4 words | trips the ear: none ...", "facts": "...", ...}
# - 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": "talk", "job": "Changing the vacuum cleaner bag | listener: Grandad | pace: steady (140 words a minute)", "steps": "1. Unplug the vacuum cleaner. | 4 words | trips the ear: none ...", "facts": "...", ...}
# - 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: "talk", job, steps, 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 tell-me-when:<task>:<inputHash>:a<attempt> (the one task is talk), 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="tell-me-when:talk:$(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 = "tell-me-when:" + 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("verdict"), reply.get("opening"))
for p in reply.get("parts", []):
print(p.get("steps"), p.get("say"), "/", p.get("check"))
import { createHash } from "node:crypto";
const key = `tell-me-when:${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, reply.opening);
for (const p of reply.parts || []) console.log(p.steps, p.say, "/", p.check);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "tell-me-when:" + 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 }
}
var reply struct {
Verdict, Opening string
Parts []struct { Steps []int; Say, Check string }
}
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply.Verdict, reply.Opening)
for _, p := range reply.Parts { fmt.Println(p.Steps, p.Say, "/", p.Check) }
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "tell-me-when:" + 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.path("verdict").asText() + " " + reply.path("opening").asText());
for (JsonNode p : reply.path("parts")) System.out.println(p.path("steps") + " " + p.path("say").asText() + " / " + p.path("check").asText());
require "digest"
key = "tell-me-when:#{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["verdict"], reply["opening"]].inspect
(reply["parts"] || []).each { |p| puts "#{p["steps"].inspect} #{p["say"]} / #{p["check"]}" }
$key = "tell-me-when:" . $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["verdict"] ?? ""), " ", ($reply["opening"] ?? ""), "\n";
foreach (($reply["parts"] ?? []) as $p) echo json_encode($p["steps"] ?? []), " ", $p["say"] ?? "", " / ", $p["check"] ?? "", "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"tell-me-when:{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?["verdict"]} {reply?["opening"]}");
foreach (var p in reply?["parts"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"{p?["steps"]?.ToJsonString()} {p?["say"]} / {p?["check"]}");
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: tell-me-when:talk:$(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": "talk", "job": "Changing the vacuum cleaner bag | listener: Grandad | pace: steady (140 words a minute)", "steps": "1. Unplug the vacuum cleaner. | 4 words | trips the ear: none ...", "facts": "...", ...}
# - 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": "tell-me-when:talk:" + 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": `tell-me-when:talk:${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", "tell-me-when:talk:"+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", "tell-me-when:talk:" + 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" => "tell-me-when:talk:#{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: tell-me-when:talk:" . 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", $"tell-me-when:talk:{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);