Drive If They Say from your own code
The one metered task, map, is available over HTTP. It takes a small real-life ask - the kind of moment, who you are asking, the ask in your words, the reply you dread, what you would settle for and how you like to sound - plus the page's own read of the ask, and returns a dialogue tree: a one-line ask, three openers, three to six replies from them with branching choices, three or four endings (a yes, a half-yes and a kind no), the dreaded reply marked, and three delivery tips.
The division of labour first: the page owns the read of the ask and the verdict, the tree checks and the route count; the model owns the lines; the page re-checks the reply. All of it comes from ifkit.js, which you can run yourself (see Build the input yourself). The other person's lines are guesses for practice, never predictions.
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":"if-they-say"}), 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, moment, who, ask, voice, page_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: writing a map 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 page's read
Every run sends task: map; a missing or different one is still answered as map. The page reads the ask with plain word rules (IfKit.signals): its word count, vague words (stuff, things, something, help me out, sort it out ...), money (a sum, lend, loan, pay for ...), something ongoing (every week, always, from now on, for good ...) and two asks stacked (and also, as well as ...).
The verdict follows a fixed order, first match wins (IfKit.verdictFor): sharpen if the ask is under four words or has a vague word; shrink if it has a money, ongoing or stacked signal; say_it otherwise. The page sends it in page_facts; the model copies it and writes the ask line to match (blanks in square brackets for sharpen, a smaller first ask without the signal phrases for shrink), and the page flags a reply that does not.
The input
Every value is a string (objects are never sent). app.js builds it with IfKit.buildInput(form).input.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always map. |
moment | yes | The kind of moment, e.g. ask a small favour (one of seven). |
who | yes | Who you are asking, in your words (at most 120 characters, the middle cut with [...]). |
ask | yes | The ask in your words (at most 300 characters). |
worry | no | The reply you dread (at most 240 characters), or none given. |
fallback | no | What you would settle for (at most 200 characters), or none given. |
voice | yes | warm, brief or playful. |
note | no | Anything else (at most 400 characters), or none given. |
page_facts | yes | The page's lines (IfKit.pageFacts): verdict, why, ask words, vague words, big-ask signals, worry, fallback and tree size. |
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 IfKit.parseReply and IfKit.normalize (a cut-off stream is closed by IfKit.closeJson and shown as "N of 9 sections recovered").
{"task": "map", "verdict": "say_it | sharpen | shrink", "verdict_reason": "...", "headline": "...", "ask_line": "...",
"openers": [{"tone": "warm | direct | playful", "say": "...", "next": "n1"}],
"nodes": [{"id": "n1", "they": "...", "choices": [{"tone": "warm | direct | playful | exit", "say": "...", "next": "n2 | e1"}]}],
"worry_node": "n2",
"endings": [{"id": "e1", "kind": "yes | partial | no", "title": "...", "line": "..."}],
"tips": ["...", "...", "..."]}
| Field | Rule |
|---|---|
ask_line | One sentence of at most 25 words, spoken to them: blanks in square brackets for sharpen, a smaller first ask for shrink. |
openers | Exactly 3 - one warm, one direct, one playful - each leading to a line from them. |
nodes | 3 to 6, ids n1, n2 ... in order; they is direct speech of at most 25 words; 2 or 3 choices with different tones; each next is a later node or an ending; an exit always ends the scene. |
worry_node | The node that voices the reply you dread (or the firmest no when none was given). |
endings | 3 or 4, ids e1 ...; kinds yes, partial and no each at least once; title at most 6 words, line at most 25. |
tips | Exactly 3, at most 18 words each: when to ask, how to say it, how to leave after a no. |
headline | At most 10 words. |
The page then checks the reply against its own facts (IfKit.check, built on IfKit.validate): the verdict, links that lead nowhere or loop back, lines and endings that can never be reached, repeated tones, exits that do not end the scene, a missing kind of ending, an unmarked dreaded reply, an ask line that kept the big-ask phrases or has no blanks when it should, lines from them written as narration, a number or a he/she the brief never gave, and internal labels in the wording. It also counts the routes through the tree (IfKit.routes).
Worked examples, one per verdict
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited.
A seat swap on a train - say_it
Input:
{
"task": "map",
"moment": "ask to swap a seat, a slot or a spot",
"who": "the person in the window seat on my train",
"ask": "Would you swap seats with me so I can sit next to my sister?",
"worry": "They say no and I have to sit there feeling awkward for the whole trip.",
"fallback": "none given",
"voice": "warm",
"note": "none given",
"page_facts": "verdict: say_it\nwhy: the ask is 14 words with no vague words, no money, nothing ongoing and one thing asked\nask words: 14\nvague words: none\nbig-ask signals: none\nworry: given\nfallback: none given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"
}
Reply:
{
"task": "map",
"verdict": "say_it",
"verdict_reason": "Your ask is short and clear, asks for just one thing, and does not involve money or anything ongoing, so you can simply say it.",
"headline": "Ask to swap seats so you can sit together",
"ask_line": "Would you mind swapping seats with me so I can sit next to my sister?",
"openers": [
{
"tone": "warm",
"say": "Hi, would you mind swapping seats with me so I can sit next to my sister?",
"next": "n1"
},
{
"tone": "direct",
"say": "Would you swap seats with me so I can sit next to my sister?",
"next": "n1"
},
{
"tone": "playful",
"say": "Any chance you'd swap seats with me so I can sit next to my sister?",
"next": "n1"
}
],
"nodes": [
{
"id": "n1",
"they": "Oh, I'm not sure — I like having the window seat.",
"choices": [
{
"tone": "warm",
"say": "No pressure, even just for a little while would help.",
"next": "n2"
},
{
"tone": "direct",
"say": "Would you be able to swap for the whole ride?",
"next": "n3"
},
{
"tone": "playful",
"say": "I promise I'll trade back if you miss the view!",
"next": "n2"
}
]
},
{
"id": "n2",
"they": "Alright, I could manage that for a little while.",
"choices": [
{
"tone": "warm",
"say": "Thank you, I appreciate it.",
"next": "e2"
},
{
"tone": "direct",
"say": "Could you manage that for the rest of the trip instead?",
"next": "n4"
}
]
},
{
"id": "n3",
"they": "No, I'd rather keep this seat for the trip.",
"choices": [
{
"tone": "warm",
"say": "That's completely understandable, thank you for thinking about it.",
"next": "e3"
},
{
"tone": "exit",
"say": "No problem, I'll stay here then.",
"next": "e3"
}
]
},
{
"id": "n4",
"they": "Alright, I don't mind swapping for the whole ride.",
"choices": [
{
"tone": "warm",
"say": "Thank you so much, I appreciate it.",
"next": "e1"
},
{
"tone": "direct",
"say": "Great, thank you — I'll swap with you now.",
"next": "e1"
}
]
}
],
"worry_node": "n3",
"endings": [
{
"id": "e1",
"kind": "yes",
"title": "They agree to swap",
"line": "They swap seats with you, and you sit next to your sister."
},
{
"id": "e2",
"kind": "partial",
"title": "They offer a shorter swap",
"line": "They agree to swap with you for a little while, so you can sit next to your sister before moving back."
},
{
"id": "e3",
"kind": "no",
"title": "They keep their seat",
"line": "They keep their seat, you thank them, and you settle into the trip without the swap."
}
],
"tips": [
"Ask soon, while it still feels easy for you to bring it up.",
"Speak slowly, pause after you ask, and look at them while you wait.",
"If they say no, thank them and settle back into your seat without pushing."
]
}
A neighbour's forgotten name - say_it (with a fallback)
Input:
{
"task": "map",
"moment": "admit you forgot their name",
"who": "a neighbour I chat with in the hallway",
"ask": "Ask them to tell me their name again without making it weird",
"worry": "They are hurt that I forgot it.",
"fallback": "Get them to say it to someone else while I listen.",
"voice": "playful",
"note": "We have chatted for about a year.",
"page_facts": "verdict: say_it\nwhy: the ask is 12 words with no vague words, no money, nothing ongoing and one thing asked\nask words: 12\nvague words: none\nbig-ask signals: none\nworry: given\nfallback: given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"
}
Reply:
{
"task": "map",
"verdict": "say_it",
"verdict_reason": "Your ask is short and clear, asks for just one specific thing, and doesn't involve money or anything ongoing, so it's ready to say as is.",
"headline": "Ask your neighbour to tell you their name again",
"ask_line": "Okay, I have to admit I've blanked on your name - what is it again?",
"openers": [
{
"tone": "warm",
"say": "Hi - this is a little embarrassing, but I've forgotten your name. Could you tell me again?",
"next": "n1"
},
{
"tone": "direct",
"say": "Quick thing - I've blanked on your name. What is it?",
"next": "n1"
},
{
"tone": "playful",
"say": "Okay, confession time: I have zero memory of your name - mind telling me again?",
"next": "n1"
}
],
"nodes": [
{
"id": "n1",
"they": "Wait, you're telling me you forgot already?",
"choices": [
{
"tone": "warm",
"say": "I know, my bad - I just want to ask you properly now.",
"next": "n2"
},
{
"tone": "playful",
"say": "Guilty as charged! Help a forgetful neighbour out?",
"next": "n2"
},
{
"tone": "direct",
"say": "Yeah, I forgot - mind telling me?",
"next": "n3"
}
]
},
{
"id": "n2",
"they": "Honestly, it stings a bit that you forgot.",
"choices": [
{
"tone": "warm",
"say": "I understand, and I really am sorry - I'd still like to know it.",
"next": "n4"
},
{
"tone": "exit",
"say": "I hear you - let's just leave it for now.",
"next": "e3"
}
]
},
{
"id": "n3",
"they": "I'd rather not just hand it over - maybe another time.",
"choices": [
{
"tone": "warm",
"say": "That's completely fair - thanks for being honest.",
"next": "e3"
},
{
"tone": "exit",
"say": "No worries at all, forget I asked.",
"next": "e3"
}
]
},
{
"id": "n4",
"they": "Alright, I suppose it's not that big a deal.",
"choices": [
{
"tone": "warm",
"say": "Thanks, I appreciate it - so, what is it?",
"next": "e1"
},
{
"tone": "direct",
"say": "Or if it's easier, you could mention it to someone nearby and I'll catch it.",
"next": "e2"
},
{
"tone": "exit",
"say": "Actually, don't worry about it - we're fine either way.",
"next": "e3"
}
]
}
],
"worry_node": "n2",
"endings": [
{
"id": "e1",
"kind": "yes",
"title": "They tell you their name",
"line": "They tell you their name right away, and the conversation carries on easily."
},
{
"id": "e2",
"kind": "partial",
"title": "They tell someone else instead",
"line": "They mention their name to someone nearby, so you catch it without having to ask directly again."
},
{
"id": "e3",
"kind": "no",
"title": "You let it go",
"line": "You decide not to push it, and you both leave the moment on a friendly note."
}
],
"tips": [
"Ask when you're next just chatting in the hallway, not in a rushed or crowded moment.",
"Say it lightly, keep easy eye contact, and pause after asking so it feels playful.",
"If they'd rather not say, drop it warmly and keep the hallway chats easy."
]
}
A vague favour at work - sharpen
Input:
{
"task": "map",
"moment": "ask a small favour",
"who": "my coworker who sits next to me",
"ask": "Could you maybe help me out with some stuff at work?",
"worry": "They think I am trying to dump my work on them.",
"fallback": "none given",
"voice": "brief",
"note": "none given",
"page_facts": "verdict: sharpen\nwhy: the ask leans on vague words (\"help me out\", \"stuff\")\nask words: 11\nvague words: help me out, stuff\nbig-ask signals: none\nworry: given\nfallback: none given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"
}
Reply:
{
"task": "map",
"verdict": "sharpen",
"verdict_reason": "Your ask leans on vague words like \"help me out\" and \"stuff\", so it helps to spell out exactly what you need.",
"headline": "Turn your vague ask into a clear one",
"ask_line": "Could you help me with [the task] for [how long]?",
"openers": [
{
"tone": "warm",
"say": "Hey, could you help me with [the task] for [how long]?",
"next": "n1"
},
{
"tone": "direct",
"say": "Can you help me with [the task] for [how long]?",
"next": "n2"
},
{
"tone": "playful",
"say": "Quick one - any chance you can help me with [the task] for [how long]?",
"next": "n1"
}
],
"nodes": [
{
"id": "n1",
"they": "What would you need, and for how long?",
"choices": [
{
"tone": "warm",
"say": "It's [the task], just for [how long], if that works.",
"next": "n4"
},
{
"tone": "direct",
"say": "I need help with [the task] for [how long].",
"next": "n2"
},
{
"tone": "exit",
"say": "Never mind, I'll sort it out myself.",
"next": "e3"
}
]
},
{
"id": "n2",
"they": "I don't want to end up doing your work for you.",
"choices": [
{
"tone": "warm",
"say": "I just need a hand, not for you to take it over.",
"next": "n4"
},
{
"tone": "direct",
"say": "It's only [the task], not ongoing work.",
"next": "n3"
},
{
"tone": "exit",
"say": "That's okay, forget I asked.",
"next": "e3"
}
]
},
{
"id": "n3",
"they": "I don't think I can take this on right now.",
"choices": [
{
"tone": "warm",
"say": "No worries, thanks anyway.",
"next": "e3"
},
{
"tone": "exit",
"say": "That's alright, I understand.",
"next": "e3"
}
]
},
{
"id": "n4",
"they": "I could probably help with that for a bit.",
"choices": [
{
"tone": "warm",
"say": "Thanks, that would really help.",
"next": "e1"
},
{
"tone": "direct",
"say": "Even a little help would be fine.",
"next": "e2"
}
]
}
],
"worry_node": "n2",
"endings": [
{
"id": "e1",
"kind": "yes",
"title": "They agree to help",
"line": "They agree to help, and you thank them and settle what you need."
},
{
"id": "e2",
"kind": "partial",
"title": "They offer a little help",
"line": "They offer to help a bit, and you accept it gladly and thank them."
},
{
"id": "e3",
"kind": "no",
"title": "They say no kindly",
"line": "They say they can't help this time, and you thank them and let it go."
}
],
"tips": [
"Ask when you're both free for a moment, not in the middle of something else.",
"Speak slowly, pause after you ask, and keep steady eye contact.",
"When they say no, thank them and move on - you don't need to push."
]
}
An ongoing ask of a neighbour - shrink
Input:
{
"task": "map",
"moment": "ask a small favour",
"who": "my next-door neighbour",
"ask": "Could you take my bins out every week from now on while I work nights?",
"worry": "They say yes to be polite and then resent it.",
"fallback": "Just this week, while I see how the night shifts go.",
"voice": "warm",
"note": "none given",
"page_facts": "verdict: shrink\nwhy: the ask involves an ongoing ask (\"every week\", \"from now on\")\nask words: 15\nvague words: none\nbig-ask signals: every week, from now on\nworry: given\nfallback: given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"
}
Reply:
{
"task": "map",
"verdict": "shrink",
"verdict_reason": "This was shrunk because you asked for an ongoing commitment, every week, from now on, rather than a one-off favour.",
"headline": "A smaller ask about taking the bins out",
"ask_line": "Would you mind taking my bins out just this week, while I see how the night shifts go?",
"openers": [
{
"tone": "warm",
"say": "Hi! Would you mind taking my bins out just this week, while I see how the night shifts go?",
"next": "n1"
},
{
"tone": "direct",
"say": "Could you take my bins out just this week, while I see how the night shifts go?",
"next": "n1"
},
{
"tone": "playful",
"say": "Quick favour - could you take my bins out just this week, while I see how the night shifts go?",
"next": "n4"
}
],
"nodes": [
{
"id": "n1",
"they": "Just this week? I'd need to think about it.",
"choices": [
{
"tone": "warm",
"say": "No pressure, just let me know whenever you can.",
"next": "n2"
},
{
"tone": "direct",
"say": "Does that mean yes or no?",
"next": "n3"
},
{
"tone": "exit",
"say": "That's okay, I'll figure something else out.",
"next": "e3"
}
]
},
{
"id": "n2",
"they": "Sure, I suppose I could do that.",
"choices": [
{
"tone": "warm",
"say": "Thank you.",
"next": "e1"
},
{
"tone": "exit",
"say": "Actually, don't worry about it, I'll manage.",
"next": "e3"
}
]
},
{
"id": "n3",
"they": "I don't think I can manage that, sorry.",
"choices": [
{
"tone": "warm",
"say": "That's okay, thank you for considering it.",
"next": "e3"
},
{
"tone": "exit",
"say": "No problem, thanks anyway.",
"next": "e3"
}
]
},
{
"id": "n4",
"they": "I could manage it just once, if that would help.",
"choices": [
{
"tone": "warm",
"say": "That would still help, thank you.",
"next": "e2"
},
{
"tone": "exit",
"say": "That's okay, don't worry about it.",
"next": "e3"
}
]
}
],
"worry_node": "n2",
"endings": [
{
"id": "e1",
"kind": "yes",
"title": "A kind yes for this week",
"line": "They agree to take the bins out this week, while you see how the night shifts go."
},
{
"id": "e2",
"kind": "partial",
"title": "Just once, not every time",
"line": "They agree to help just once this week, not every time."
},
{
"id": "e3",
"kind": "no",
"title": "A kind no for now",
"line": "They don't take on the bins, and you leave it there."
}
],
"tips": [
"Ask at a calm moment, not when either of you is rushing off.",
"Speak slowly, pause after you ask, and look at them while you wait.",
"If they say no, thank them warmly and drop the subject right away."
]
}
Build the input yourself
Load the script in node (global.window = global; require("./ifkit.js")), then:
const K = IfKit;
const form = K.form({ moment: "sendback", who: "the server at the cafe", ask: "Could you warm my soup up a little more?",
worry: "They look annoyed", fallback: "", voice: "brief", note: "" });
K.signals(form.ask); // {words: 9, vague: [], money: [], ongoing: [], stacked: []}
K.verdictFor(form).verdict; // "say_it" - the page's rule, first match wins
K.sketch(form); // the free tree: 3 openers, 3 lines from them, 3 endings
K.validate(K.sketch(form)); // {problems: [], routes: 8, longest: 2}
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, form).notes // the page's re-check of the tree
// K.md(form, reply) // every branch as a Markdown outline
// K.card(form, reply) // the pocket card
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://if-they-say.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://if-they-say.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"]
// if-they-say.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://if-they-say.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://if-they-say.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
}
// IfTheySay.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 IfTheySay {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://if-they-say.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://if-they-say.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://if-they-say.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://if-they-say.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":"if-they-say"}' | 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": "if-they-say"})["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: "if-they-say" })).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": "if-they-say"}, 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", "if-they-say"), 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: "if-they-say" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "if-they-say"])["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 = "if-they-say" }))!["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", "moment": "ask to swap a seat, a slot or a spot", "who": "the person in the window seat on my train", "ask": "Would you swap seats with me so I can sit next to my sister?", "worry": "They say no and I have to sit there feeling awkward for the whole trip.", "fallback": "none given", "voice": "warm", "note": "none given", "page_facts": "verdict: say_it\nwhy: the ask is 14 words with no vague words, no money, nothing ongoing and one thing asked\nask words: 14\nvague words: none\nbig-ask signals: none\nworry: given\nfallback: none given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"}
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", "moment": "ask to swap a seat, a slot or a spot", "who": "the person in the window seat on my train", "ask": "Would you swap seats with me so I can sit next to my sister?", "worry": "They say no and I have to sit there feeling awkward for the whole trip.", "fallback": "none given", "voice": "warm", "note": "none given", "page_facts": "verdict: say_it\nwhy: the ask is 14 words with no vague words, no money, nothing ongoing and one thing asked\nask words: 14\nvague words: none\nbig-ask signals: none\nworry: given\nfallback: none given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"}
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", moment, who, ask, worry, fallback, voice, note, page_facts, 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 if-they-say:<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="if-they-say: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 = "if-they-say:" + 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("headline"))
import { createHash } from "node:crypto";
const key = `if-they-say:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "if-they-say:" + 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 = "if-they-say:" + 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 = "if-they-say:#{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["headline"]].inspect
$key = "if-they-say:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"if-they-say:{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: if-they-say: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", "moment": "ask to swap a seat, a slot or a spot", "who": "the person in the window seat on my train", "ask": "Would you swap seats with me so I can sit next to my sister?", "worry": "They say no and I have to sit there feeling awkward for the whole trip.", "fallback": "none given", "voice": "warm", "note": "none given", "page_facts": "verdict: say_it\nwhy: the ask is 14 words with no vague words, no money, nothing ongoing and one thing asked\nask words: 14\nvague words: none\nbig-ask signals: none\nworry: given\nfallback: none given\ntree size: 3-6 lines from them, 3-4 endings, 2-3 choices per line"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "if-they-say: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": `if-they-say: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", "if-they-say: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", "if-they-say: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" => "if-they-say: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: if-they-say: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", $"if-they-say: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);