Drive Whose Serve from your own code
The one metered task, read, is available over HTTP. It takes a text thread between two people - each message marked you or them and grouped into rallies - together with the page's own figures (messages, words, questions, serves and double hits per side, the carry share, who sent the last message) and its line calls, and returns a verdict (wait, you-carry, they-carry or even), two to five hedged reads that quote the thread word for word, three reply drafts (light, warm, direct) aimed at the goal, up to three things to avoid, one question to ask yourself, and a care note whenever a line call was made. A natural use is a journaling or coaching tool that keeps a person's own threads and wants a second opinion before they hit send.
The division of labour first: the page owns the counting; the model owns the judgement and the words; the page re-checks the reply. Reading the thread, the rallies, the counts, the carry share and the line calls come from rallykit.js, which runs in node as well as in the browser. The model never counts anything: RallyKit.buildInput hands it every figure. RallyKit.check holds each reply to them: the verdict by the rule, every quote inside the message it points at, three drafts in order, a care note whenever there is a line call, and no number, capitalised name, he/she or field name that the input does not hold.
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":"whose-serve"}), 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, goal, messages, figures) 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: reading the rally 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 rule
Every run sends task: read (the only task; a missing or unknown one is treated as read). The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
- wait - you have sent 3 or more messages since their last one (
trailing_youis 3 or more). - you-carry - you carry 65% or more of the rally (
you_share). - they-carry - you carry 35% or less.
- even - everything else.
The carry share averages your share of the words, of the questions (when anyone asked one) and of the serves (when there are two or more rallies) - a rough rule of thumb, not a measure of anyone's feelings. The declared input schema carries scalars only, so the array- and object-valued fields (messages, figures, flags) travel JSON-encoded as strings (RallyKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | read. Required. |
goal, goal_label | string | keep_going, make_plan, slow_down, clear_air or let_go, and its plain label. goal is required. |
stage, stage_label | string | matched, few_dates, seeing or together, and its plain label. |
them_name | string | The other person's name as written in the thread, or empty. |
messages | string (JSON) | [{id, from, text, rally}] - ids M1.., up to the last 80 messages; from is you or them; each text up to 400 characters. Required. |
figures | string (JSON) | {you_messages, them_messages, you_words, them_words, you_questions, them_questions, you_serves, them_serves, you_doubles, them_doubles, you_median_words, them_median_words, you_share, rallies, last_from, trailing_you}, every value a string. Required. |
flags | string (JSON) | [{kind, label, ref}] - line calls on their messages: money, code, off_app, invest, pressure. |
notes | string | Free text from you, up to 300 characters. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, signals (2-5 {ref, quote, tone, read}), replies (three {style, text, why}, styles light, warm, direct), avoid (0-3 strings), ask_yourself and care (empty unless there is a line call). Parse with RallyKit.parseReply, then normalize(reply), then check(reply, built) where built = RallyKit.buildInput(form); md(reply, built, form) writes the Markdown and csv(built) the rally sheet.
Worked examples
Replies produced against the app's prompt for the four sample threads (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict.
a first match, an even rally - even
Input:
{
"task": "read",
"them_name": "Sam",
"goal": "make_plan",
"goal_label": "suggest meeting up",
"stage": "matched",
"stage_label": "just matched, only texting so far",
"messages": "[{\"id\":\"M1\",\"from\":\"them\",\"text\":\"ok I have to ask, is that you on the orange route in your third photo?\",\"rally\":1},{\"id\":\"M2\",\"from\":\"you\",\"text\":\"guilty. fell off the top move about nine times before that photo\",\"rally\":1},{\"id\":\"M3\",\"from\":\"you\",\"text\":\"do you climb?\",\"rally\":1},{\"id\":\"M4\",\"from\":\"them\",\"text\":\"badly but with enthusiasm. mostly the easy wall near the door\",\"rally\":1},{\"id\":\"M5\",\"from\":\"them\",\"tex ... (1257 characters)",
"figures": "{\"you_messages\":\"6\",\"them_messages\":\"7\",\"you_words\":\"52\",\"them_words\":\"78\",\"you_questions\":\"3\",\"them_questions\":\"3\",\"you_serves\":\"1\",\"them_serves\":\"1\",\"you_doubles\":\"1\",\"them_doubles\":\"2\",\"you_median_words\":\"8\",\"them_median_words\":\"11\",\"you_share\":\"47\",\"rallies\":\"2\",\"last_from\":\"them\",\"trailing_you\":\"0\"}",
"flags": "[]",
"notes": "We matched on Sunday. Both of us like bouldering."
}
Reply:
{
"task": "read",
"verdict": "even",
"title": "Even rally, Sam's invite is yours to answer",
"summary": "You're carrying 47% of this exchange, with 1 opening rally each, 3 questions each, and Sam writing more words than you (78 vs 52). Sam just asked a question, so the ball is with you now.",
"signals": [
{
"ref": "M1",
"quote": "is that you on the orange route in your third photo",
"tone": "warm",
"read": "This reads as curiosity about you specifically, which may signal real interest beyond small talk."
},
{
"ref": "M7",
"quote": "my sister bought me a pass for my birthday",
"tone": "warm",
"read": "Sharing a personal detail here could signal comfort opening up early, though it's a small detail."
},
{
"ref": "M11",
"quote": "please tell me yours is equally ridiculous",
"tone": "warm",
"read": "The teasing ask for a matching chalk bag may be a flirty, playful push, not a demand."
},
{
"ref": "M13",
"quote": "would you be up for climbing together sometime",
"tone": "warm",
"read": "This is a direct invite to climb together, which could be an opening for the plan you want."
}
],
"replies": [
{
"style": "light",
"text": "Yeah, I'd be up for that! Climbing together sounds fun.",
"why": "Keeps it easy and says yes."
},
{
"style": "warm",
"text": "I'd love that - climbing together sounds like a great next step after all this chalk bag talk.",
"why": "Warmly agrees and references your back-and-forth."
},
{
"style": "direct",
"text": "Let's do it - I'm in for climbing together, just say when works for you.",
"why": "States clear yes and invites Sam to set a time."
}
],
"avoid": [
"Letting Sam's invite sit too long without a reply",
"Turning a simple yes into a long message"
],
"ask_yourself": "Do you want to suggest a specific activity, or just say yes and let Sam propose details?",
"care": ""
}
two dates in, you do all the asking - you-carry
Input:
{
"task": "read",
"them_name": "Jordan",
"goal": "keep_going",
"goal_label": "keep the conversation going",
"stage": "few_dates",
"stage_label": "been on a date or two",
"messages": "[{\"id\":\"M1\",\"from\":\"you\",\"text\":\"hey! I had a really good time on Saturday, even with the rain chasing us out of the market. did you get home dry?\",\"rally\":1},{\"id\":\"M2\",\"from\":\"them\",\"text\":\"yeah fine\",\"rally\":1},{\"id\":\"M3\",\"from\":\"you\",\"text\":\"good! I tried that dumpling place you mentioned when I got back, the chilli oil was amazing. have you been back since?\",\"rally\":1},{\"id\":\"M4\",\"from\":\"them\",\"text\":\"not yet\",\" ... (1101 characters)",
"figures": "{\"you_messages\":\"5\",\"them_messages\":\"5\",\"you_words\":\"115\",\"them_words\":\"12\",\"you_questions\":\"5\",\"them_questions\":\"0\",\"you_serves\":\"2\",\"them_serves\":\"0\",\"you_doubles\":\"0\",\"them_doubles\":\"0\",\"you_median_words\":\"24\",\"them_median_words\":\"2\",\"you_share\":\"97\",\"rallies\":\"2\",\"last_from\":\"them\",\"trailing_you\":\"0\"}",
"flags": "[]",
"notes": "Two dates so far. The second one ended early because of the rain."
}
Reply:
{
"task": "read",
"verdict": "you-carry",
"title": "You're carrying 97% of this",
"summary": "You're carrying 97% of this exchange, opening both rallies and asking every question while Jordan answers in a word or two. Jordan sent the last message, so the ball is with you.",
"signals": [
{
"ref": "M2",
"quote": "yeah fine",
"tone": "cool",
"read": "Reads short and flat - may land as low-energy, though it could just be a quick reply."
},
{
"ref": "M6",
"quote": "it went ok",
"tone": "neutral",
"read": "Answers the question but doesn't pick up the dumpling plan you floated just before it."
},
{
"ref": "M8",
"quote": "haha nice",
"tone": "warm",
"read": "A small laugh and a warm word - could be genuine warmth or just easy acknowledgment."
},
{
"ref": "M10",
"quote": "it was good",
"tone": "neutral",
"read": "A short answer to a question you were excited about - may or may not invite more."
}
],
"replies": [
{
"style": "light",
"text": "Ha, \"good\" is doing a lot of work there - what was your favorite part?",
"why": "Playful nudge to get more than a short answer."
},
{
"style": "warm",
"text": "Glad it was good - I'm dying to talk through the ending with you.",
"why": "Warm, keeps the book thread open."
},
{
"style": "direct",
"text": "What actually stood out to you about the ending? I'd love the full reaction, not just the short version.",
"why": "Directly asks for more than a short answer."
}
],
"avoid": [
"sending another stack of questions before Jordan answers",
"answering a short reply with a long message"
],
"ask_yourself": "Are you comfortable carrying most of this for now, or is it time to ease off a little?",
"care": ""
}
a fast stranger who asks to move apps and for a gift card - they-carry, with line calls
Input:
{
"task": "read",
"them_name": "Alex",
"goal": "slow_down",
"goal_label": "slow things down",
"stage": "matched",
"stage_label": "just matched, only texting so far",
"messages": "[{\"id\":\"M1\",\"from\":\"them\",\"text\":\"Good morning beautiful soul. I couldn't stop thinking about your smile all night, I have never felt a connection this fast\",\"rally\":1},{\"id\":\"M2\",\"from\":\"them\",\"text\":\"Tell me everything about your day, I want to know all of it. What do you eat for breakfast? What makes you happy?\",\"rally\":1},{\"id\":\"M3\",\"from\":\"you\",\"text\":\"haha morning. just toast and coffee\",\"rally\":1},{\"id\":\"M4\",\" ... (1241 characters)",
"figures": "{\"you_messages\":\"3\",\"them_messages\":\"6\",\"you_words\":\"19\",\"them_words\":\"143\",\"you_questions\":\"0\",\"them_questions\":\"3\",\"you_serves\":\"0\",\"them_serves\":\"1\",\"you_doubles\":\"0\",\"them_doubles\":\"3\",\"you_median_words\":\"6\",\"them_median_words\":\"23\",\"you_share\":\"6\",\"rallies\":\"1\",\"last_from\":\"you\",\"trailing_you\":\"1\"}",
"flags": "[{\"kind\":\"off_app\",\"label\":\"Wants to move off the app\",\"ref\":\"M5\"},{\"kind\":\"money\",\"label\":\"Asks for money\",\"ref\":\"M8\"},{\"kind\":\"pressure\",\"label\":\"Pressure or secrecy\",\"ref\":\"M8\"}]",
"notes": "Matched two days ago. Never met or called."
}
Reply:
{
"task": "read",
"verdict": "they-carry",
"title": "Alex is carrying this - it's Alex's turn",
"summary": "Alex sent 6 messages to your 3, asked 3 questions to your 0, and you're carrying 6% of the talking. Since you sent the last message, the next move is Alex's.",
"signals": [
{
"ref": "M1",
"quote": "I have never felt a connection this fast",
"tone": "warm",
"read": "This reads as very strong feeling for a first message, which may feel fast rather than grounded."
},
{
"ref": "M4",
"quote": "Do you believe in fate?",
"tone": "warm",
"read": "This keeps the intense tone from the first message and asks a big question early, which may be worth noticing."
},
{
"ref": "M5",
"quote": "Text me on my other number",
"tone": "neutral",
"read": "This asks to move the conversation off the app early, which is flagged and may be worth pausing on."
},
{
"ref": "M8",
"quote": "If you really cared you would help me",
"tone": "cool",
"read": "This links money to how much you care, which reads as pressure rather than a simple request for help."
}
],
"replies": [
{
"style": "light",
"text": "Hey, I'd like to slow this down a bit for now. Nothing against you, I just want to take my time.",
"why": "Signals a pause without blame"
},
{
"style": "warm",
"text": "I've enjoyed chatting, Alex, but I want to slow things down and get to know you at my own pace.",
"why": "Keeps it kind while asking for space"
},
{
"style": "direct",
"text": "I'm not comfortable moving this fast, including the money ask. I'd rather slow down and keep talking here, if at all.",
"why": "States the boundary plainly"
}
],
"avoid": [
"sending money, a gift card, or moving off the app",
"matching the fast, heavy compliments with personal details right away",
"feeling you owe a quick reply after one rally"
],
"ask_yourself": "Does slowing down feel okay to you, even if Alex pushes to speed things back up?",
"care": "The messages ask for a $100 gift card and to text off the app. Never send money, gift cards or codes to someone you haven't met, and don't move off the app early. Talk to a friend about it."
}
three messages in a row from you - wait
Input:
{
"task": "read",
"them_name": "Riley",
"goal": "keep_going",
"goal_label": "keep the conversation going",
"stage": "few_dates",
"stage_label": "been on a date or two",
"messages": "[{\"id\":\"M1\",\"from\":\"them\",\"text\":\"that pasta place was so good, thank you for picking it\",\"rally\":1},{\"id\":\"M2\",\"from\":\"you\",\"text\":\"thank YOU for the company, the tiramisu was a great call\",\"rally\":1},{\"id\":\"M3\",\"from\":\"them\",\"text\":\"next time dessert is on me\",\"rally\":1},{\"id\":\"M4\",\"from\":\"you\",\"text\":\"deal. how was the rest of your week?\",\"rally\":1},{\"id\":\"M5\",\"from\":\"them\",\"text\":\"busy but fine! big deadline thur ... (696 characters)",
"figures": "{\"you_messages\":\"5\",\"them_messages\":\"3\",\"you_words\":\"43\",\"them_words\":\"23\",\"you_questions\":\"2\",\"them_questions\":\"0\",\"you_serves\":\"3\",\"them_serves\":\"1\",\"you_doubles\":\"0\",\"them_doubles\":\"0\",\"you_median_words\":\"8\",\"them_median_words\":\"6\",\"you_share\":\"80\",\"rallies\":\"4\",\"last_from\":\"you\",\"trailing_you\":\"3\"}",
"flags": "[]",
"notes": "We have been on one date, a week ago."
}
Reply:
{
"task": "read",
"verdict": "wait",
"title": "Three in a row - time to let it land",
"summary": "You carried 80% of this exchange, opening 3 of the 4 rallies and asking the only two questions while Riley asked none. The ball is with Riley now, since your last three messages haven't had a reply.",
"signals": [
{
"ref": "M3",
"quote": "next time dessert is on me",
"tone": "warm",
"read": "may read as Riley looking ahead to meeting again, though it's a light, open-ended line."
},
{
"ref": "M5",
"quote": "big deadline thursday",
"tone": "neutral",
"read": "could just be a plain update, or a sign Riley was pressed for time that day."
},
{
"ref": "M8",
"quote": "are we still on for dessert sometime",
"tone": "neutral",
"read": "reads as a direct check-in that hasn't had a reply yet, waiting on Riley's answer."
}
],
"replies": [
{
"style": "light",
"text": "Totally fine if you're busy, Riley - the offer for dessert still stands whenever you're free.",
"why": "keeps it light, no pressure, leaves it open"
},
{
"style": "warm",
"text": "Hope the week eased up, Riley. Dessert's still on whenever works for you.",
"why": "warm close, keeps the door open without pushing"
},
{
"style": "direct",
"text": "Riley, if the offer's still open, let me know when dessert works for you.",
"why": "names the ask plainly without demanding a reply"
}
],
"avoid": [
"adding another message before Riley replies",
"stacking more questions on top of the one still open"
],
"ask_yourself": "If Riley doesn't reply soon, are you comfortable waiting without sending another message?",
"care": ""
}
Build the input yourself
Load the script in node (global.window = global; require("./rallykit.js")), then:
const K = RallyKit;
const form = K.form({ you: "Me", goal: "keep_going", stage: "matched",
thread: "Sam: did you see the match last night?\nMe: I did! what a finish\nMe: you?\nSam: only the end, sadly" });
const r = K.read(form); // r.msgs: who sent what, rallies, serves; r.fig: counts and the verdict; r.calls
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.csv(built) // the rally sheet, one row per message
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://whose-serve.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://whose-serve.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"]
// whose-serve.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://whose-serve.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://whose-serve.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
}
// WhoseServe.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 WhoseServe {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://whose-serve.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://whose-serve.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://whose-serve.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://whose-serve.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":"whose-serve"}' | 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": "whose-serve"})["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: "whose-serve" })).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": "whose-serve"}, 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", "whose-serve"), 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: "whose-serve" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "whose-serve"])["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 = "whose-serve" }))!["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": "read", "them_name": "Sam", "goal": "make_plan", "goal_label": "suggest meeting up", "stage": "matched", "stage_label": "just matched, only texting so far", "messages": "[{\"id\":\"M1\",\"from\":\"them\",\"text\":\"ok I have to ask, is that you on the orange route in your third photo?\",\"rally\":1},{\"id\":\"M2\",\"from\":\"you\",\"text\":\"guilty. fell off the top move about nine times before that photo\",\"rally\":1}, ...]", "figures": "{...}", "flags": "[]", "notes": "We matched on Sunday. Both of us like bouldering."}
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": "read", "them_name": "Sam", "goal": "make_plan", "goal_label": "suggest meeting up", "stage": "matched", "stage_label": "just matched, only texting so far", "messages": "[{\"id\":\"M1\",\"from\":\"them\",\"text\":\"ok I have to ask, is that you on the orange route in your third photo?\",\"rally\":1},{\"id\":\"M2\",\"from\":\"you\",\"text\":\"guilty. fell off the top move about nine times before that photo\",\"rally\":1}, ...]", "figures": "{...}", "flags": "[]", "notes": "We matched on Sunday. Both of us like bouldering."}
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: "read", them_name, goal, goal_label, stage, stage_label, messages, figures, flags, notes, ... (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 whose-serve:<task>:<inputHash>:a<attempt> (the one task is read), 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="whose-serve:read:$(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 = "whose-serve:" + 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"))
import { createHash } from "node:crypto";
const key = `whose-serve:${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);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "whose-serve:" + 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 = "whose-serve:" + 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 = "whose-serve:#{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 = "whose-serve:" . $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 = $"whose-serve:{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: whose-serve:read:$(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": "read", "them_name": "Sam", "goal": "make_plan", "goal_label": "suggest meeting up", "stage": "matched", "stage_label": "just matched, only texting so far", "messages": "[{\"id\":\"M1\",\"from\":\"them\",\"text\":\"ok I have to ask, is that you on the orange route in your third photo?\",\"rally\":1},{\"id\":\"M2\",\"from\":\"you\",\"text\":\"guilty. fell off the top move about nine times before that photo\",\"rally\":1}, ...]", "figures": "{...}", "flags": "[]", "notes": "We matched on Sunday. Both of us like bouldering."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "whose-serve:read:" + 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": `whose-serve:read:${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", "whose-serve:read:"+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", "whose-serve:read:" + 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" => "whose-serve:read:#{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: whose-serve:read:" . 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", $"whose-serve:read:{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);