Drive Link a Day from your own code
The one metered task, chain, is available over HTTP. It takes a group and its December as the page's 24-day layout - every day from 1 to 24 December with its kind, its minute limit, its share of the budget and any note - and returns two or three threads (recurring rituals, each with a purpose) and 24 links: one small thing to do together per day, with minutes, an estimated cost, what to get ready ahead and who starts it, plus the links that are safe to skip and how to pick up after a missed day.
The division of labour first: the page owns the calendar and the rule; the model owns the rituals and the words; the page re-checks the reply. The day kinds, the limits, the budget shares and the verdict come from chainkit.js, which you can run yourself (see Build the input yourself).
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":"link-a-day"}), 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, crew_type, days, figures, page_verdict, open_days) 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: fitting the chain 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: chain (fit the chain); a missing or different one is still answered as chain. Each day gets one kind in a fixed order: planned (a date on the planned list), day off (a weekend day when weekends are off), busy (a busy date or busy weekday), free (any other weekend day), else an ordinary weekday. The verdict follows a fixed rule on the light days - busy, planned or off:
- roomy - up to 6 light days. Three threads, no link marked safe to skip.
- snug - 7 to 12. Three threads, exactly one link safe to skip.
- packed - 13 or more. Two threads, exactly two links safe to skip.
The reply must copy page_verdict; the page flags any reply that does not.
The run input
Every value is a string (objects are never sent). app.js builds it with ChainKit.buildInput(form).input.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always chain. |
crew_type | yes | family with kids, couple, flatmates, friends or remote team. |
crew | no | Who is in the group, in the person's words (at most 200 characters), or not described. |
likes / avoid | no | What the group enjoys (600 characters) and what to leave out (400), or nothing listed. |
tone / faith | no | cosy, playful, reflective or mixed; secular or include faith moments. |
currency / budget | no | The money symbol (or none) and the whole chain's budget as a whole number. |
days | yes | 24 lines: <day> | <date> | <kind> | up to <N> min | up to <M> | <note>, kinds quick, standard, big, pinned, rest. |
figures | yes | The page's counts of each kind of day, the light days, the budget and the verdict. |
page_verdict | yes | roomy, snug or packed, from the rule above. |
open_days | yes | The busy and ordinary weekdays, the only days that may be marked safe to skip. |
clip_note | no | What the page shortened (long text, long day notes), or absent. |
retry_note | no | Sent only on the page's one reformat retry. |
The reply
The reply is one JSON object in job.output.output. The page parses it with ChainKit.parseReply and ChainKit.normalize (a cut-off stream is closed by ChainKit.closeJson and shown as "N of 7 sections recovered", with starter links on the days that did not arrive).
{"task": "chain", "verdict": "roomy | snug | packed", "title": "...", "summary": "...",
"threads": [{"id": "a", "name": "...", "purpose": "..."}],
"links": [{"day": 1, "thread": "a", "title": "...", "do": "...", "minutes": 5, "cost": 0, "prep": "", "lead": "..."}],
"skip_ok": [3],
"keep_going": "..."}
| Field | Contract |
|---|---|
threads | Three (roomy, snug) or two (packed), ids a, b, c; each holds at least four links. |
links | Exactly 24, day 1 to 24 in order. minutes from 1 to the day's limit, cost from 0 to the day's share; planned days and days off cost nothing and need no prep; day 24 looks back over the chain. |
skip_ok | By verdict (above), only from open_days, never day 24. |
keep_going | One sentence on picking up after a missed day. |
The page then checks the reply against its own layout (ChainKit.check): the verdict, the thread count, missing or doubled days, unknown thread ids, minutes and costs over each day's limit, the budget total, free planned days and days off, the skip count and which days were skipped, field names leaking into the wording, weekdays or dates in the wording, faith content in a secular chain and anything on the avoid list. What to get ready ahead is added up by the page from the links' prep, not by the model.
Worked examples, one per verdict
These are the three free samples on the page: the exact input and a stand-in model's reply, unedited.
A couple with lots of free evenings - roomy
Input:
{
"task": "chain",
"crew_type": "couple",
"crew": "the two of us, both working from home",
"likes": "board games, cooking something new, old films, walks by the river, writing letters",
"avoid": "alcohol, long phone calls",
"tone": "cosy",
"faith": "secular",
"currency": "£",
"budget": "40",
"days": "1 | Tue 1 Dec | standard | up to 25 min | up to 2 |\n2 | Wed 2 Dec | standard | up to 25 min | up to 2 |\n3 | Thu 3 Dec | standard | up to 25 min | up to 2 |\n4 | Fri 4 Dec | standard | up to 25 min | up to 2 |\n5 | Sat 5 Dec | big | up to 60 min | up to 3 |\n6 | Sun 6 Dec | big | up to 60 min | up to 3 ... (24 lines, 1314 characters)",
"figures": "2 busy days; 16 ordinary weekdays; 5 free weekend days; 1 day with something planned; 0 days off; 3 light days (busy, planned or off); budget £40; verdict roomy",
"page_verdict": "roomy",
"open_days": "1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 17, 18, 21, 22, 23, 24"
}
Reply (the first six links shown):
{
"task": "chain",
"verdict": "roomy",
"title": "Twenty-four little things to share together",
"summary": "This chain holds 2 busier evenings, 16 ordinary weekdays, 5 free weekend days and 1 day with something already planned, with 0 days off and a budget of £40 for the whole chain. In plain words, there is plenty of space around each day, so the chain should feel light and easy to keep going.",
"threads": [
{
"id": "a",
"name": "Games and Films",
"purpose": "gives you something playful to share and look forward to together"
},
{
"id": "b",
"name": "New Recipes",
"purpose": "lets you make something new together and enjoy what you have made"
},
{
"id": "c",
"name": "Walks and Letters",
"purpose": "gives you quiet moments to notice the days and put something into words"
}
],
"links": [
{
"day": 1,
"thread": "a",
"title": "Pick a board game",
"do": "Choose a board game together and play through one full round.",
"minutes": 20,
"cost": 0,
"prep": "",
"lead": "the two of you"
},
{
"day": 2,
"thread": "c",
"title": "Write a short letter",
"do": "Write each other a short letter and put it aside unopened.",
"minutes": 15,
"cost": 0,
"prep": "",
"lead": "the two of you"
},
{
"day": 3,
"thread": "b",
"title": "Try a new recipe",
"do": "Pick a recipe neither of you has made before and cook it together.",
"minutes": 25,
"cost": 2,
"prep": "ingredients for one new recipe",
"lead": "the two of you"
},
{
"day": 4,
"thread": "c",
"title": "Walk by the river",
"do": "Walk together along the river and each name one thing you notice.",
"minutes": 25,
"cost": 0,
"prep": "",
"lead": "the two of you"
},
{
"day": 5,
"thread": "b",
"title": "Cook a full new dish",
"do": "Choose a bigger recipe you have not tried and cook it together from start to finish.",
"minutes": 55,
"cost": 3,
"prep": "ingredients for the recipe",
"lead": "the two of you"
},
{
"day": 6,
"thread": "a",
"title": "Watch an old film",
"do": "Pick an old film you both love and watch it all the way through together.",
"minutes": 60,
"cost": 0,
"prep": "",
"lead": "the two of you"
},
"... 18 more links, same shape"
],
"skip_ok": [],
"keep_going": "If a day gets missed, pick it up together next time you are both free and carry on from there."
}
A family with clubs and a school play - snug
Input:
{
"task": "chain",
"crew_type": "family with kids",
"crew": "two adults and two kids, 6 and 9",
"likes": "drawing, baking, silly songs, walks after dark, building with blocks",
"avoid": "nuts, anything loud after 8pm",
"tone": "playful",
"faith": "include faith moments",
"currency": "$",
"budget": "30",
"days": "1 | Tue 1 Dec | standard | up to 20 min | up to 1 |\n2 | Wed 2 Dec | quick | up to 10 min | up to 1 |\n3 | Thu 3 Dec | standard | up to 20 min | up to 1 |\n4 | Fri 4 Dec | quick | up to 10 min | up to 1 | busy: late shift\n5 | Sat 5 Dec | big | up to 45 min | up to 3 |\n6 | Sun 6 Dec | big | up to 45 min ... (24 lines, 1314 characters)",
"figures": "9 busy days; 8 ordinary weekdays; 5 free weekend days; 2 days with something planned; 0 days off; 11 light days (busy, planned or off); budget $30; verdict snug",
"page_verdict": "snug",
"open_days": "1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 18, 21, 22, 23, 24"
}
Reply (the first six links shown):
{
"task": "chain",
"verdict": "snug",
"title": "Twenty-four small things to share together",
"summary": "The chain has 9 busy days, 8 ordinary weekdays, 5 free weekend days, 2 days with something planned, 0 days off and a budget of 30. That makes for a fairly full month, with just enough room to enjoy each small ritual without feeling rushed.",
"threads": [
{
"id": "a",
"name": "Make Together",
"purpose": "Gives everyone a chance to make something with their hands."
},
{
"id": "b",
"name": "Silly Together",
"purpose": "Brings you together through shared laughter and silly fun."
},
{
"id": "c",
"name": "Quiet Together",
"purpose": "Gives you space to pause, notice and be thankful."
}
],
"links": [
{
"day": 1,
"thread": "a",
"title": "Draw A Link Together",
"do": "Draw a small picture together for the chain.",
"minutes": 15,
"cost": 0,
"prep": "",
"lead": "the kids"
},
{
"day": 2,
"thread": "c",
"title": "A Quiet Minute Together",
"do": "Sit together for a quiet minute and each say one good thing about the day.",
"minutes": 5,
"cost": 0,
"prep": "",
"lead": "everyone"
},
{
"day": 3,
"thread": "b",
"title": "Silly Song Swap",
"do": "Take turns making up a silly song about something that happened today.",
"minutes": 15,
"cost": 0,
"prep": "",
"lead": "anyone"
},
{
"day": 4,
"thread": "c",
"title": "A Short Quiet Pause",
"do": "Pause together for a short prayer or reading of your choosing, in your own words.",
"minutes": 5,
"cost": 0,
"prep": "",
"lead": "everyone"
},
{
"day": 5,
"thread": "a",
"title": "Bake Something Together",
"do": "Bake a treat together from start to finish, taking turns with each step.",
"minutes": 40,
"cost": 3,
"prep": "baking ingredients",
"lead": "everyone"
},
{
"day": 6,
"thread": "b",
"title": "Walk And Sing After Dark",
"do": "Go for a walk together after dark, making up silly songs as you go.",
"minutes": 40,
"cost": 0,
"prep": "",
"lead": "everyone"
},
"... 18 more links, same shape"
],
"skip_ok": [
9
],
"keep_going": "If a day gets missed, just tear off two links the next day and carry on without worry."
}
A remote team, weekends off - packed
Input:
{
"task": "chain",
"crew_type": "remote team",
"crew": "a remote team of seven across three time zones",
"likes": "music, show and tell, quick quizzes, sharing photos",
"avoid": "anything that needs cameras on, food or drink",
"tone": "mixed",
"faith": "secular",
"currency": "none",
"budget": "0",
"days": "1 | Tue 1 Dec | standard | up to 15 min | up to 0 |\n2 | Wed 2 Dec | standard | up to 15 min | up to 0 |\n3 | Thu 3 Dec | quick | up to 5 min | up to 0 |\n4 | Fri 4 Dec | standard | up to 15 min | up to 0 |\n5 | Sat 5 Dec | rest | up to 2 min | up to 0 |\n6 | Sun 6 Dec | rest | up to 2 min | up to 0 |\n7 ... (24 lines, 1317 characters)",
"figures": "8 busy days; 8 ordinary weekdays; 0 free weekend days; 2 days with something planned; 6 days off; 16 light days (busy, planned or off); budget 0 (every link free); verdict packed",
"page_verdict": "packed",
"open_days": "1, 2, 3, 4, 7, 8, 9, 10, 14, 15, 16, 17, 21, 22, 23, 24"
}
Reply (the first six links shown):
{
"task": "chain",
"verdict": "packed",
"title": "Twenty-four small moments to share together",
"summary": "Eight days are busy, eight are ordinary weekdays, none are free weekend days, two already have something planned, and six are days off, with a budget of zero so every link is free. With sixteen of the days already light, planned or off, the chain leaves little room to spare, so the shorter links will help it keep moving.",
"threads": [
{
"id": "a",
"name": "Team Check-In",
"purpose": "gives everyone a moment to connect across the time zones"
},
{
"id": "b",
"name": "Spark Moment",
"purpose": "brings a short burst of music or play to lift a busy day"
}
],
"links": [
{
"day": 1,
"thread": "a",
"title": "Share One Small Photo",
"do": "Post a photo in the team chat that shows something small from your day, no explanation needed.",
"minutes": 10,
"cost": 0,
"prep": "",
"lead": "anyone"
},
{
"day": 2,
"thread": "b",
"title": "Quick Team Quiz",
"do": "Post three quick questions in the team chat and let everyone answer whenever they are free.",
"minutes": 10,
"cost": 0,
"prep": "",
"lead": "today's host"
},
{
"day": 3,
"thread": "b",
"title": "One Song For Today",
"do": "Post one song in the team chat that fits your mood right now.",
"minutes": 5,
"cost": 0,
"prep": "",
"lead": "anyone"
},
{
"day": 4,
"thread": "a",
"title": "One Word Check-In",
"do": "Share one word in the team chat for how your week is going so far.",
"minutes": 10,
"cost": 0,
"prep": "",
"lead": "anyone"
},
{
"day": 5,
"thread": "a",
"title": "A Quiet Thank You",
"do": "Pause for a moment and send one thank-you message to someone else in the group.",
"minutes": 2,
"cost": 0,
"prep": "",
"lead": "everyone"
},
{
"day": 6,
"thread": "a",
"title": "One Small Gratitude",
"do": "Take a short pause and send one thing you are grateful for this week.",
"minutes": 2,
"cost": 0,
"prep": "",
"lead": "everyone"
},
"... 18 more links, same shape"
],
"skip_ok": [
3,
21
],
"keep_going": "If a day gets missed, carry its idea into the next open link instead of trying to catch it up."
}
Build the input yourself
Load the script in node (global.window = global; require("./chainkit.js")), then:
const K = ChainKit;
const form = K.form({ year: 2026, crew_type: "flatmates", crew: "four flatmates", likes: "cooking, films, board games",
weekends: "together", busy_wd: [2, 4], busy: "9 late shift", planned: "18 flat dinner", budget: 40, currency: "£" });
const L = K.layout(form); // L.days: kind, minute limit, budget share per day; L.fig: counts and verdict
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.ics(built, reply) // the 24 links as an all-day calendar file
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://link-a-day.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://link-a-day.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"]
// link-a-day.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://link-a-day.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://link-a-day.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
}
// LinkADay.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 LinkADay {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://link-a-day.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://link-a-day.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://link-a-day.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://link-a-day.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":"link-a-day"}' | 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": "link-a-day"})["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: "link-a-day" })).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": "link-a-day"}, 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", "link-a-day"), 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: "link-a-day" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "link-a-day"])["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 = "link-a-day" }))!["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": "chain", "crew_type": "family with kids", "crew": "two adults and two kids, 6 and 9", "likes": "drawing, baking, silly songs, walks after dark, building with blocks", "avoid": "nuts, anything loud after 8pm", "tone": "playful", "faith": "include faith moments", "currency": "$", "budget": "30", "days": "1 | Tue 1 Dec | standard | up to 20 min | up to 1 |\n2 | Wed 2 Dec | quick | up to 10 min | up to 1 |\n...", "figures": "9 busy days; 8 ordinary weekdays; 5 free weekend days; 2 days with something planned; 0 days off; 11 light days (busy, planned or off); budget $30; verdict snug", "page_verdict": "snug", "open_days": "1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 18, 21, 22, 23, 24"}
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": "chain", "crew_type": "family with kids", "crew": "two adults and two kids, 6 and 9", "likes": "drawing, baking, silly songs, walks after dark, building with blocks", "avoid": "nuts, anything loud after 8pm", "tone": "playful", "faith": "include faith moments", "currency": "$", "budget": "30", "days": "1 | Tue 1 Dec | standard | up to 20 min | up to 1 |\n2 | Wed 2 Dec | quick | up to 10 min | up to 1 |\n...", "figures": "9 busy days; 8 ordinary weekdays; 5 free weekend days; 2 days with something planned; 0 days off; 11 light days (busy, planned or off); budget $30; verdict snug", "page_verdict": "snug", "open_days": "1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 18, 21, 22, 23, 24"}
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: "chain", crew_type, crew, likes, avoid, tone, faith, currency, budget, days, figures, page_verdict, open_days, clip_note} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses link-a-day:<task>:<inputHash>:a<attempt> (the one task is chain), 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="link-a-day:chain:$(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 = "link-a-day:" + 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("title"))
import { createHash } from "node:crypto";
const key = `link-a-day:${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.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "link-a-day:" + 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 = "link-a-day:" + 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 = "link-a-day:#{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 = "link-a-day:" . $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 = $"link-a-day:{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: link-a-day:chain:$(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": "chain", "crew_type": "family with kids", "crew": "two adults and two kids, 6 and 9", "likes": "drawing, baking, silly songs, walks after dark, building with blocks", "avoid": "nuts, anything loud after 8pm", "tone": "playful", "faith": "include faith moments", "currency": "$", "budget": "30", "days": "1 | Tue 1 Dec | standard | up to 20 min | up to 1 |\n2 | Wed 2 Dec | quick | up to 10 min | up to 1 |\n...", "figures": "9 busy days; 8 ordinary weekdays; 5 free weekend days; 2 days with something planned; 0 days off; 11 light days (busy, planned or off); budget $30; verdict snug", "page_verdict": "snug", "open_days": "1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 18, 21, 22, 23, 24"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "link-a-day:chain:" + 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": `link-a-day:chain:${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", "link-a-day:chain:"+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", "link-a-day:chain:" + 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" => "link-a-day:chain:#{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: link-a-day:chain:" . 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", $"link-a-day:chain:{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);