Drive Chirp Sheet from your own code
Both metered lanes are available over HTTP. card takes a rivalry dossier - both names, what they compete at, the head-to-head record, the heat (1 playful, 2 solid chirp, 3 spicy but clean), off-limits topics and the ammo - and returns a verdict, full-card, short-card or no-card, with three or two rounds of trash-talk lines (each tied to the ammo it stands on, with an angle and a heat rating) and a self-own. check takes the same dossier plus one line you wrote and returns send, tweak or bench, with corner notes and a rewrite. A natural use is a group-chat bot that keeps the league's dossier and checks a chirp before it posts.
The division of labour first: the page owns the corner, the record and the limits; the model writes the lines; the page re-checks the reply. The dossier reader, the corner and the clipping come from chirpkit.js, which runs in node as well as in the browser. Run ChirpKit.buildInput on anything you send: it drops every piece of ammo that touches an off-limits topic or someone's looks, family, money, health or identity, so the model never sees it. ChirpKit.check holds each reply to the dossier: the round count matches the verdict, every round cites ammo that was sent, no line is hotter than the heat you set, longer than 200 characters, or touches an off-limits word or a low blow, and no capitalised name, number or pronoun turns up that the dossier 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":"chirp-sheet"}), 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 such as task, you, rival or ammo 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: both lanes are metered and need 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 verdicts
Every run sends task: card or check; a missing or unknown task is treated as card. A piece of ammo is chirpable when it is about the rival (them) or a shared moment (us) and names something specific they did, said or always do; a bare insult ("trash", "so slow") is not, and me ammo never is. Each lane decides its verdict by three ordered, non-overlapping rules - the first that applies wins:
| Lane | Order | Verdict | When |
|---|---|---|---|
| card | 1 | no-card | No chirpable ammo. No rounds; asks holds two or three questions to dig up a specific moment. |
| card | 2 | short-card | One or two chirpable pieces: exactly two rounds, opener and closer. |
| card | 3 | full-card | Three or more: exactly three rounds, opener, callback and closer, with heat that never drops. |
| check | 1 | bench | The line touches an off-limits topic or a low blow (looks, family, money or work, health, identity), or reads as a real insult. The rewrite is a different line on a different subject. |
| check | 2 | tweak | Otherwise, if it is over 200 characters, states something the dossier contradicts or does not hold, runs hotter than the heat set, or buries the punchline. The rewrite keeps the joke. |
| check | 3 | send | None of the above. No issues, no rewrite. |
The declared input schema carries scalars only, so the array-valued off_limits and ammo fields travel JSON-encoded as strings (ChirpKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | card or check. Required. |
you, rival | string | The two names as typed, up to 24 characters each. Required. |
arena | string | What the two compete at, up to 80 characters, or empty. |
you_wins, rival_wins | string | Whole numbers, or both empty when nobody keeps score. |
max_heat | string | "1", "2" or "3". |
off_limits | string (JSON) | Up to 10 topics as typed, e.g. ["Jordan's job","the divorce"]; may be []. |
held_back | string | How many pieces of ammo the page kept in the corner; "0" when none. |
ammo | string (JSON) | [{id, about, text}] - ids A1.., up to 16 pieces of up to 200 characters; about is them, me or us, from a "them:", "me:" or "us:" prefix. Required. |
draft, draft_chars | string | check only: the line to judge (up to 600 characters) and its length as counted by the page. |
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. Both lanes carry task, verdict, title and summary. card adds rounds ([{id, label, line, ammo, angle, heat}]; ids R1..; label is opener, callback or closer; angle is callback, record, prediction, absurd or deadpan; heat 1-3), self_own ({line, ammo}) and asks. check adds issues ([{rule, note}]; rule is off-limits, low-blow, ungrounded, too-long, too-hot or unclear), heat (of the draft), rewrite ({line, ammo}) and lands. Parse with ChirpKit.parseReply, then normalize(reply, task), then check(reply, form), which returns the page's notes.
Worked examples
Replies produced against the app's prompt for the six sample runs (made-up rivalries; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict of both lanes.
card - Sam vs Jordan, pickup basketball, four specific moments: full-card
Input:
{
"task": "card",
"you": "Sam",
"rival": "Jordan",
"arena": "Sunday pickup basketball",
"you_wins": "4",
"rival_wins": "6",
"max_heat": "2",
"off_limits": "[\"Jordan's job\",\"the divorce\"]",
"held_back": "1",
"ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)"
}
Reply:
{
"task": "card",
"verdict": "full-card",
"title": "The Timeout Call Vs The Sneaker Show",
"summary": "Sam and Jordan have more than enough ammo to spare, from the timeout call to the rim excuses and the brand-new shoes, so this one earns a full set of rounds. Those three moments carry the card from a light opener to a closer with some bite.",
"rounds": [
{
"id": "R1",
"label": "opener",
"line": "Jordan, new shoes every week and still will not jump - the sneakers are getting a better workout than you are.",
"ammo": [
"A3"
],
"angle": "absurd",
"heat": 1
},
{
"id": "R2",
"label": "callback",
"line": "Jordan, that timeout you called mid pickup game still gets brought up - pickup games do not have timeouts, but apparently you do.",
"ammo": [
"A1"
],
"angle": "callback",
"heat": 2
},
"... (3 in all)"
],
"self_own": {
"line": "I air-balled a free throw that nobody even asked me to take - genuinely impressive, if the goal was embarrassing myself.",
"ammo": [
"A5"
]
},
"asks": []
}
card - Riley vs Morgan, the office pool, two specific moments: short-card
Input:
{
"task": "card",
"you": "Riley",
"rival": "Morgan",
"arena": "the office baseball playoff pool",
"you_wins": "1",
"rival_wins": "1",
"max_heat": "1",
"off_limits": "[]",
"held_back": "0",
"ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"picked every series by which team has the nicer uniforms\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"still drinks from last year's \\\"pool champion\\\" mug every morning\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"total clown\"},{\"id\":\"A4\",\"about\":\"me\",\"text\":\"forgot to submit my picks for the first round\"}]"
}
Reply:
{
"task": "card",
"verdict": "short-card",
"title": "The Uniform Pick vs the Champion Mug",
"summary": "This card leans on Morgan's uniform-based picks and the pool champion mug ritual, the two stories with real receipts. The total clown line had no story behind it, so it is left out, and the head-to-head record sits level. Two rounds are enough to land clean chirps without padding the card.",
"rounds": [
{
"id": "R1",
"label": "opener",
"line": "You picked every series by the nicer uniforms - a bold strategy for the office pool.",
"ammo": [
"A1"
],
"angle": "absurd",
"heat": 1
},
{
"id": "R2",
"label": "closer",
"line": "The head-to-head is 1-1, and you're still drinking from last year's pool champion mug every morning.",
"ammo": [
"A2"
],
"angle": "record",
"heat": 1
}
],
"self_own": {
"line": "I forgot to submit my picks for the first round, so maybe I'm the one who should skip the trash talk.",
"ammo": [
"A4"
]
},
"asks": []
}
card - Alex vs Casey, couch kart racing, only bare insults: no-card
Input:
{
"task": "card",
"you": "Alex",
"rival": "Casey",
"arena": "couch kart racing",
"you_wins": "",
"rival_wins": "",
"max_heat": "3",
"off_limits": "[]",
"held_back": "1",
"ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"bad\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"so slow\"},{\"id\":\"A3\",\"about\":\"me\",\"text\":\"always picks the same character\"}]"
}
Reply:
{
"task": "card",
"verdict": "no-card",
"title": "Alex Vs Casey Couch Kart Clash",
"summary": "Alex and Casey only have bare jabs on hand, with no real story behind either one to build a line on. One more piece of ammo stayed in the corner, so what it says stays unknown for now. Casey needs a specific moment before this card can open.",
"rounds": [],
"self_own": {
"line": "",
"ammo": []
},
"asks": [
"What is the most embarrassing crash Casey has had in couch kart racing?",
"Has Casey ever used a shady shortcut to win a race?",
"What is the funniest excuse Casey has given after a loss?"
]
}
check - the timeout line: send
Input:
{
"task": "check",
"you": "Sam",
"rival": "Jordan",
"arena": "Sunday pickup basketball",
"you_wins": "4",
"rival_wins": "6",
"max_heat": "2",
"off_limits": "[\"Jordan's job\",\"the divorce\"]",
"held_back": "1",
"ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)",
"draft": "Jordan, you called a timeout in pickup. Bold. Next week try calling one on the rim too, since it's apparently a little loose.",
"draft_chars": "125"
}
Reply:
{
"task": "check",
"verdict": "send",
"title": "The Loose Rim Timeout",
"summary": "This line from Sam sends as written, stacking Jordan's pickup timeout call and the loose-rim excuse into one tidy jab. It stays clear of any low blow and keeps the heat within the limit for the group chat.",
"issues": [],
"heat": 2,
"rewrite": {
"line": "",
"ammo": []
},
"lands": "The line keeps two real moments from Jordan - the pickup timeout call and the loose-rim excuse - and lands by turning the excuse into a second timeout call."
}
check - the long drive-home rant that gets the record wrong: tweak
Input:
{
"task": "check",
"you": "Sam",
"rival": "Jordan",
"arena": "Sunday pickup basketball",
"you_wins": "4",
"rival_wins": "6",
"max_heat": "2",
"off_limits": "[\"Jordan's job\",\"the divorce\"]",
"held_back": "1",
"ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)",
"draft": "So Jordan, I was thinking about it on the drive home and honestly after all these weeks of losing every single game to me you might want to consider that it isn't the rim and it isn't the shoes, it might just be that you can't shoot, also you called a timeout lol",
"draft_chars": "263"
}
Reply:
{
"task": "check",
"verdict": "tweak",
"title": "The Excuses and Timeout Line",
"summary": "The line runs well past the message limit and claims a losing streak that the head-to-head record does not support, since Jordan actually leads Sam in wins. It also lands hotter than Sam wants and tucks the timeout callback behind too much wind-up. The rewrite keeps the rim-and-shoes setup and the timeout call but trims it down and drops the invented streak.",
"issues": [
{
"rule": "too-long",
"note": "The line runs to 263 characters, well past the 200-character limit for a group-chat message."
},
{
"rule": "ungrounded",
"note": "It claims Sam has been winning every game, but the record actually has Jordan ahead of Sam."
},
{
"rule": "too-hot",
"note": "Flatly declaring that Jordan can't shoot lands hotter than the solid-chirp heat Sam asked for."
}
],
"heat": 3,
"rewrite": {
"line": "Jordan, it's not the rim and it's not the shoes - and nobody's forgotten the timeout you called in a game with no timeouts.",
"ammo": [
"A1",
"A2",
"A3"
]
},
"lands": "The pivot from blaming the rim and the shoes to pointing out Jordan called a timeout is what actually lands."
}
check - a jab at an off-limits job: bench
Input:
{
"task": "check",
"you": "Sam",
"rival": "Jordan",
"arena": "Sunday pickup basketball",
"you_wins": "4",
"rival_wins": "6",
"max_heat": "2",
"off_limits": "[\"Jordan's job\",\"the divorce\"]",
"held_back": "1",
"ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"},{\"id\":\"A3\",\"about\":\"them\",\"text\":\"shows up in brand-new shoes every week and still won't jump\"},{\"id\":\"A4\",\"about\":\"us\",\"text\":\"we both missed the same wide-open layup on the last play last week\"},{\"id\":\"A5\",\"about\":\"me\",\"t ... (518 characters)",
"draft": "Maybe spend less time at that job you hate and more time on your jump shot.",
"draft_chars": "75"
}
Reply:
{
"task": "check",
"verdict": "bench",
"title": "The Off-Limits Dig",
"summary": "Sam's draft reaches into a subject the dossier marked off-limits, turning a tease into a low blow instead of fun ribbing. The rewrite drops that subject and leans on Jordan's new shoes and refusal to jump instead.",
"issues": [
{
"rule": "off-limits",
"note": "The draft brings up Jordan's job, which the dossier marked off-limits."
},
{
"rule": "low-blow",
"note": "Jabbing at someone's job crosses from ribbing into a real sting, not clean fun."
}
],
"heat": 2,
"rewrite": {
"line": "New shoes every week, Jordan, and you still won't jump.",
"ammo": [
"A3"
]
},
"lands": "Nothing in the draft lands safely, so the rewrite instead leans on Jordan's new shoes and refusal to jump for the laugh."
}
Build the input yourself
Load the script in node (global.window = global; require("./chirpkit.js")), then:
const K = ChirpKit;
const form = K.form({ you: "Sam", rival: "Jordan", arena: "pickup basketball", you_wins: "4", rival_wins: "6", heat: "2",
off_limits: "Jordan's job", ammo: "them: called a timeout in pickup\nthem: is going bald\nme: air-balled a free throw",
draft: "Jordan, you called a timeout in pickup. Bold." });
console.log(K.flags("card", form), K.leanCard(form), K.ammo(form).held); // the flags, the page's lean, the ammo in the corner
const wire = K.toWire(K.buildInput("card", form).input); // POST this to /estimate and /run ("check" adds the draft)
// then: const card = K.normalize(K.parseReply(job.output.output), "card");
// const { notes } = K.check(card, form); // the page's re-check of the reply
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://chirp-sheet.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://chirp-sheet.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"]
// chirp-sheet.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://chirp-sheet.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://chirp-sheet.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
}
// ChirpSheet.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 ChirpSheet {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://chirp-sheet.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://chirp-sheet.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://chirp-sheet.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://chirp-sheet.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":"chirp-sheet"}' | 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": "chirp-sheet"})["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: "chirp-sheet" })).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": "chirp-sheet"}, 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", "chirp-sheet"), 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: "chirp-sheet" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "chirp-sheet"])["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 = "chirp-sheet" }))!["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": "card", "you": "Sam", "rival": "Jordan", "arena": "Sunday pickup basketball", "you_wins": "4", "rival_wins": "6", "max_heat": "2", "off_limits": "[\"Jordan's job\",\"the divorce\"]", "held_back": "1", "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"}, ...]"}
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": "card", "you": "Sam", "rival": "Jordan", "arena": "Sunday pickup basketball", "you_wins": "4", "rival_wins": "6", "max_heat": "2", "off_limits": "[\"Jordan's job\",\"the divorce\"]", "held_back": "1", "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"}, ...]"}
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: "card", you, rival, arena, max_heat, off_limits, ammo, ... (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 chirp-sheet:<task>:<inputHash>:a<attempt> (the lanes are card and check), 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="chirp-sheet:card:$(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 = "chirp-sheet:" + 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 = `chirp-sheet:${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 := "chirp-sheet:" + 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 = "chirp-sheet:" + 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 = "chirp-sheet:#{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 = "chirp-sheet:" . $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 = $"chirp-sheet:{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: chirp-sheet:card:$(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": "card", "you": "Sam", "rival": "Jordan", "arena": "Sunday pickup basketball", "you_wins": "4", "rival_wins": "6", "max_heat": "2", "off_limits": "[\"Jordan's job\",\"the divorce\"]", "held_back": "1", "ammo": "[{\"id\":\"A1\",\"about\":\"them\",\"text\":\"called a timeout in a pickup game, where there are no timeouts\"},{\"id\":\"A2\",\"about\":\"them\",\"text\":\"blames every missed shot on the rim being \\\"a little loose\\\"\"}, ...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "chirp-sheet:card:" + 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": `chirp-sheet:card:${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", "chirp-sheet:card:"+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", "chirp-sheet:card:" + 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" => "chirp-sheet:card:#{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: chirp-sheet:card:" . 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", $"chirp-sheet:card:{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);