Drive RSVP By from your own code
The one metered read is available over HTTP: give it a party's name, occasion and vibe, the twelve detail stamps (any of them blank) and free notes, and it returns a verdict, the clashes to fix first, one guest question per blank stamp with what to add, the invite written in the vibe (headline, blurb, detail lines, reply line), a day-before reminder text and answers to the questions guests are likely to send. A natural use is an event or group-chat tool that wants a ready-to-paste invite and a list of what is still missing.
The division of labour first: the page owns the sheet, the dates and the clash check; the model writes; the page re-checks the reply. The stamps, the weekday arithmetic, the hard clashes (an end before the start, a reply-by after the party, a weekday the date doesn't have) and the verdict (by the same ordered rules the model gets) come from invitekit.js, which runs in node as well as in the browser. Its check function holds each reply to the sheet: one gap entry per blank stamp and none invented, every clash answered, no weekday the dates don't have, no line for a blank stamp, no internal field names in prose. Dates and times reach the model already written out, so it never computes a weekday.
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":"rsvp-by"}), 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 is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the written invite 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
Every run sends task: invite - there is one lane. The verdict is decided by three ordered, non-overlapping rules; the first that matches wins:
| Order | Verdict | When |
|---|---|---|
| 1 | clash | page_clashes is not empty, or two facts in the input cannot both be true (a stamp against the notes, or two stamps). |
| 2 | gaps | Otherwise, an essential stamp (Date, Start time, Where, RSVP by) is blank, or more than three of the other eight are. |
| 3 | ready | Everything else. |
The declared input schema carries scalars only, so array-valued fields travel JSON-encoded as strings (InviteKit.toWire does this). Every value is a string; a blank stamp is an empty string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | invite. |
name | string | The party in the host's words, up to 120 characters. |
occasion | string | One of birthday, Halloween party, Friendsgiving, watch party, game night, housewarming, dinner party, holiday party, something else, or empty. |
vibe | string | One of cozy, chaotic fun, low-key, dressy, wholesome, or empty. |
date, rsvp | string | Written out with the weekday, e.g. Saturday 31 October 2026 (the page converts its ISO dates). |
start, end | string | Written out, e.g. 8:00 pm, noon. An end earlier than the start and before 6 am means after midnight. |
place, access, host, food, bring, dress | string | Free text, up to 200 characters each: where, getting in, hosted by, food and drinks, bring, wear. |
plus | string | welcome, ask first, not this time, or empty. |
kids | string | welcome, ask first, adults only, or empty. |
notes | string | Free notes for guests; past 1,500 characters the middle is cut with a marker. |
page_missing | string (JSON) | Array of the blank stamps' labels in sheet order: Date, Start time, Wraps up, Where, Getting in, Hosted by, Food and drinks, Bring, Wear, Plus-ones, Kids, RSVP by. |
page_clashes | string (JSON) | Array of the hard clashes the page found, as sentences. |
page_lean | string | The page's verdict from the stamps alone, by the rules above. |
page_flags | string (JSON) | Array of the page's warnings. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, clashes ([{clash, fix}], empty unless the verdict is clash), gaps ([{detail, question, fill}], one per label in page_missing, in order), invite ({headline, blurb, details: [{label, text}], rsvp}), reminder and faq ([{q, a}], 2-5; an answer the input cannot give starts with "Not on the invite yet -"). Parse it with InviteKit.parseReply, then normalize(reply), then check(reply, form), which returns {notes, lean}.
Worked examples
Replies produced against the app's prompt for the four sample parties (made-up parties, names and addresses; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict, including one where the reply finds a clash in the notes that the sheet alone cannot see.
costume party - ready
Input:
{
"task": "invite",
"name": "Haunted house party at ours",
"occasion": "Halloween party",
"vibe": "chaotic fun",
"date": "Saturday 31 October 2026",
"start": "8:00 pm",
"end": "1:00 am",
"place": "22 Larch Road, top floor flat",
"access": "street parking after 6 pm; ring the bell marked Top Flat",
"host": "Dami and Kit",
"food": "pizza at 9, plus a punch bowl and an alcohol-free punch",
"bring": "a snack or a drink if you like",
"dress": "costumes encouraged - prize for the best one",
"plus": "welcome",
"kids": "adults only",
"rsvp": "Tuesday 27 October 2026",
"notes": "We have a cat who hides upstairs - please keep the bedroom doors shut. Costume contest at 11.",
"page_missing": "[]",
"page_clashes": "[]",
"page_lean": "ready",
"page_flags": "[]"
}
Reply:
{
"task": "invite",
"verdict": "ready",
"title": "Haunted House Party, Ready to Go",
"summary": "Your sheet is ready - every stamp guests need is filled in and nothing clashes. Give the costume and cat notes one more glance, then send it out so people can plan their outfits in time.",
"clashes": [],
"gaps": [],
"invite": {
"headline": "Our Haunted House Party Is ON! 🎃",
"blurb": "It's happening: our Haunted House Party, on Halloween night itself. Dami and Kit are opening up the top floor flat for a full costume takeover, with a costume contest at 11 and a prize for the best look. Come hungry, come costumed, and come ready for chaos.",
"details": [
{
"label": "When",
"text": "Sat 31 Oct, 8:00 pm - 1:00 am"
},
{
"label": "Where",
"text": "22 Larch Road, top floor flat - street parking after 6 pm, ring the bell marked Top Flat"
},
{
"label": "Hosted by",
"text": "Dami and Kit"
},
"... (9 in all)"
],
"rsvp": "RSVP to Dami and Kit by Tuesday 27 Oct!"
},
"reminder": "Tomorrow's the night - our Haunted House Party kicks off at 8:00 pm at 22 Larch Road, top floor flat. Dust off those costumes, there's a prize for the best one!",
"faq": [
{
"q": "Do I need a costume?",
"a": "Costumes are encouraged, and there's a prize for the best one - the costume contest is at 11."
},
{
"q": "What time does it wrap up?",
"a": "The party runs until 1:00 am."
},
"... (5 in all)"
]
}
Friendsgiving - gaps
Input:
{
"task": "invite",
"name": "Friendsgiving at our flat",
"occasion": "Friendsgiving",
"vibe": "cozy",
"date": "Saturday 21 November 2026",
"start": "4:00 pm",
"end": "",
"place": "our flat",
"access": "",
"host": "",
"food": "turkey, gravy and pie",
"bring": "a side dish",
"dress": "",
"plus": "",
"kids": "",
"rsvp": "",
"notes": "We'll go round the table and each say one thing we're grateful for.",
"page_missing": "[\"Wraps up\",\"Getting in\",\"Hosted by\",\"Wear\",\"Plus-ones\",\"Kids\",\"RSVP by\"]",
"page_clashes": "[]",
"page_lean": "gaps",
"page_flags": "[]"
}
Reply:
{
"task": "invite",
"verdict": "gaps",
"title": "Cozy Friendsgiving, a few blanks to fill",
"summary": "Your Friendsgiving invite is close, but it still has several blank stamps - including the reply-by stamp - so this reads as gaps rather than ready. Fill in when you need replies, plus the wrap-up time, host, dress, plus-ones, kids and getting-in details before you send it out. The core facts - date, time, place and food - are already set.",
"clashes": [],
"gaps": [
{
"detail": "Wraps up",
"question": "What time should we plan to head out?",
"fill": "Add an end time so guests know when the evening wraps up."
},
{
"detail": "Getting in",
"question": "Is there parking, or how do we get in - which floor or door?",
"fill": "Add a note on parking, the door or the floor to help guests get in."
},
"... (7 in all)"
],
"invite": {
"headline": "A Cozy Friendsgiving at Our Flat",
"blurb": "You're invited to Friendsgiving at our flat - a cozy, unhurried evening of turkey, gravy and pie with the people we're thankful for. We'll go round the table and each share one thing we're grateful for, so come with a thought ready.",
"details": [
{
"label": "When",
"text": "Saturday 21 November 2026 at 4:00 pm"
},
{
"label": "Where",
"text": "Our flat"
},
{
"label": "Food and drinks",
"text": "Turkey, gravy and pie"
},
"... (4 in all)"
],
"rsvp": "Let us know soon if you can make it!"
},
"reminder": "See you tomorrow at 4:00 pm at our flat - don't forget your side dish!",
"faq": [
{
"q": "What time should I show up?",
"a": "4:00 pm on Saturday 21 November 2026, at our flat."
},
{
"q": "What should I bring?",
"a": "A side dish - turkey, gravy and pie are already taken care of."
},
"... (5 in all)"
]
}
birthday dinner - clash on the sheet
Input:
{
"task": "invite",
"name": "Jess turns 40 - Saturday dinner",
"occasion": "birthday",
"vibe": "dressy",
"date": "Friday 6 November 2026",
"start": "7:30 pm",
"end": "11:00 pm",
"place": "the back room at Quillberry Kitchen, 5 Mill Street",
"access": "two minutes' walk from the station",
"host": "Ana",
"food": "set menu; everyone pays for their own",
"bring": "nothing, just yourselves",
"dress": "smart - jackets optional",
"plus": "ask first",
"kids": "adults only",
"rsvp": "Monday 9 November 2026",
"notes": "It's a surprise - please don't mention it to Jess. The restaurant needs final numbers from me.",
"page_missing": "[]",
"page_clashes": "[\"Replies are due Monday 9 November 2026, after the party on Friday 6 November 2026.\",\"Your wording says Saturday, but 6 November 2026 is a Friday.\"]",
"page_lean": "clash",
"page_flags": "[]"
}
Reply:
{
"task": "invite",
"verdict": "clash",
"title": "Jess's 40th dinner, two date clashes",
"summary": "Your sheet is a clash: the reply-by date lands three days after the party itself, and the party's name calls it a Saturday while the date stamp says Friday 6 November 2026. Settle which date is right in each case - move the reply-by earlier and confirm the correct weekday - before you send this to guests.",
"clashes": [
{
"clash": "The reply-by date (Monday 9 November 2026) comes three days after the party itself (Friday 6 November 2026), so guests would be asked to reply after it's already happened.",
"fix": "Settle on which date is accurate, then move the reply-by date earlier than the party or adjust the party date itself, whichever is wrong."
},
{
"clash": "The party's name calls it a Saturday dinner, but the date stamp gives Friday 6 November 2026 - the two don't agree on the day of the week.",
"fix": "Figure out which day is correct and update the other one to match - either the wording in the party's name or the date stamp, not both."
}
],
"gaps": [],
"invite": {
"headline": "A Surprise Dinner for Jess's 40th",
"blurb": "You're invited to a surprise dinner celebrating Jess turning 40, hosted by Ana. It's a polished evening out - and still a secret, so please don't breathe a word to Jess beforehand.",
"details": [
{
"label": "When",
"text": "Friday 6 November 2026, 7:30 pm to 11:00 pm."
},
{
"label": "Where",
"text": "The back room at Quillberry Kitchen, 5 Mill Street - two minutes' walk from the station."
},
{
"label": "Hosted by",
"text": "Ana."
},
"... (8 in all)"
],
"rsvp": "Let Ana know as soon as you can, so they can confirm final numbers with the restaurant."
},
"reminder": "Tomorrow's the night for Jess's surprise 40th, 7:30 pm at the back room at Quillberry Kitchen, 5 Mill Street. It's dressy - smart, jackets optional - and still a surprise, so not a word to Jess!",
"faq": [
{
"q": "Is the party on Friday or Saturday?",
"a": "Not settled yet - the party's name calls it a Saturday dinner, but the date on the sheet says Friday 6 November 2026, so Ana still needs to confirm which one's right."
},
{
"q": "When's the latest I can RSVP?",
"a": "Not settled yet - the reply-by date on the sheet falls after the party date, so Ana needs to set a reply-by date that comes before the party."
},
"... (5 in all)"
]
}
watch party - the notes clash with a stamp
Input:
{
"task": "invite",
"name": "Big game watch party in the basement",
"occasion": "watch party",
"vibe": "low-key",
"date": "Friday 30 October 2026",
"start": "7:00 pm",
"end": "11:00 pm",
"place": "Theo's basement, 9 Birch Court",
"access": "use the side door, it's unlocked",
"host": "Theo",
"food": "wings and nachos",
"bring": "BYOB - bring whatever you're drinking",
"dress": "your team's colours",
"plus": "welcome",
"kids": "welcome",
"rsvp": "Wednesday 28 October 2026",
"notes": "Heads up: this is a dry house now, so no alcohol please. Kids can play upstairs.",
"page_missing": "[]",
"page_clashes": "[]",
"page_lean": "ready",
"page_flags": "[]"
}
Reply:
{
"task": "invite",
"verdict": "clash",
"title": "Big Game Watch Party, drinks clash",
"summary": "This comes out as a clash, not ready, because the bring stamp asks guests to bring their own drinks while your notes say the house is now dry - the two can't both be true. Decide which one stands and update the bring stamp or the note before you send anything. Everything else on the sheet is filled in and ready to go.",
"clashes": [
{
"clash": "The bring stamp tells guests to bring their own drinks, but your notes say this is now a dry house with no alcohol allowed.",
"fix": "Decide whether alcohol is allowed and change either the bring stamp or the note to match."
}
],
"gaps": [],
"invite": {
"headline": "Big Game Watch Party",
"blurb": "It's watch party time in Theo's basement - a low-key night for the big game. Kids are welcome too.",
"details": [
{
"label": "When",
"text": "Fri 30 Oct, 7:00 pm - 11:00 pm"
},
{
"label": "Where",
"text": "Theo's basement, 9 Birch Court - use the side door, it's unlocked"
},
{
"label": "Hosted by",
"text": "Theo"
},
"... (8 in all)"
],
"rsvp": "Let Theo know by Wed 28 Oct."
},
"reminder": "Game's on tomorrow at 7:00 pm - Theo's basement, 9 Birch Court. Use the side door, it's unlocked.",
"faq": [
{
"q": "What should I bring?",
"a": "Not settled yet - the sheet says both bring your own drinks and no alcohol in the house, so Theo needs to decide which one stands before anyone brings a drink."
},
{
"q": "Is there food?",
"a": "Yes - wings and nachos will be there."
},
"... (5 in all)"
]
}
Build the input yourself
Load the script in node (global.window = global; require("./invitekit.js")), then:
const K = InviteKit;
const form = { name: "Friendsgiving at our flat", occasion: "Friendsgiving", vibe: "cozy", notes: "",
date: "2026-11-21", start: "16:00", end: "", place: "our flat", access: "", host: "", food: "turkey and pie",
bring: "a side dish", dress: "", plus: "", kids: "", rsvp: "" };
console.log(K.lean(form), K.clashes(form)); // the page's verdict and its hard clashes
const wire = K.toWire(K.buildInput(form).input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(reply, form); // the page's re-check of the reply
// K.ics({ form, res: reply }) // a calendar file for the guests
// stamp ids: K.STAMPS.map(s => s.id)
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://rsvp-by.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://rsvp-by.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"]
// rsvp-by.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://rsvp-by.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://rsvp-by.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
}
// RsvpBy.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 RsvpBy {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://rsvp-by.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://rsvp-by.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://rsvp-by.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://rsvp-by.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":"rsvp-by"}' | 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": "rsvp-by"})["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: "rsvp-by" })).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": "rsvp-by"}, 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", "rsvp-by"), 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: "rsvp-by" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "rsvp-by"])["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 = "rsvp-by" }))!["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": "invite", "name": "Haunted house party at ours", "occasion": "Halloween party", "vibe": "chaotic fun", "date": "Saturday 31 October 2026", "start": "8:00 pm", "end": "1:00 am", "place": "22 Larch Road, top floor flat", ..., "rsvp": "Tuesday 27 October 2026", "notes": "...", "page_missing": "[]", "page_clashes": "[]", "page_lean": "ready", "page_flags": "[]"}
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": "invite", "name": "Haunted house party at ours", "occasion": "Halloween party", "vibe": "chaotic fun", "date": "Saturday 31 October 2026", "start": "8:00 pm", "end": "1:00 am", "place": "22 Larch Road, top floor flat", ..., "rsvp": "Tuesday 27 October 2026", "notes": "...", "page_missing": "[]", "page_clashes": "[]", "page_lean": "ready", "page_flags": "[]"}
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: "invite", name, date, start, place, ..., page_missing, ... (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 rsvp-by:<task>:<inputHash>:a<attempt> (there is one lane, invite), 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="rsvp-by:invite:$(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 = "rsvp-by:" + 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 read as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `rsvp-by:${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 := "rsvp-by:" + 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 = "rsvp-by:" + 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 = "rsvp-by:#{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 = "rsvp-by:" . $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 = $"rsvp-by:{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: rsvp-by:invite:$(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": "invite", "name": "Haunted house party at ours", "occasion": "Halloween party", "vibe": "chaotic fun", "date": "Saturday 31 October 2026", "start": "8:00 pm", "end": "1:00 am", "place": "22 Larch Road, top floor flat", ..., "rsvp": "Tuesday 27 October 2026", "notes": "...", "page_missing": "[]", "page_clashes": "[]", "page_lean": "ready", "page_flags": "[]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "rsvp-by:invite:" + 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": `rsvp-by:invite:${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", "rsvp-by:invite:"+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", "rsvp-by:invite:" + 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" => "rsvp-by:invite:#{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: rsvp-by:invite:" . 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", $"rsvp-by:invite:{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);