Driving Quote Generator from your own code
Quote Generator is a SkillSafe app, so everything the web page does is reachable over HTTP. The
contract is small and it is the same in both directions: you post one flat object describing the
batch you want, and you get back one plain-text document — no JSON, no code fences
— holding the aphorisms and the model's account of how each one is built. This page
documents that exactly as app.js implements it.
Base URL: https://api.skillsafe.ai/v1/app-api
Slug: quote-generator ·
Model: gpt-terra, which resolves to gpt-5.6-terra.
Runs are metered and cost credits; /me and /estimate do not.
What the API does not give you, and it is the important part. Neither of the
two checks this app is built around runs on the server. The known-saying check
(corpus.js and known.js) and the craft check (craft.js)
both run in your browser, after the reply lands, over every field of it. Over the API
you receive the model's text and nothing else: no match report, no craft findings, no crowding
figure.
So treat an API reply the way this app treats one before it has checked it — as a draft that has not yet been looked at. Those three files are plain JavaScript with no dependencies and no network calls. If you want the check, take them.
The envelope
Every response carries the same wrapper. ok tells you which branch you are on;
the payload is under data, the failure under error.
{"ok": true, "data": {"job_id": "job_...", "status": "succeeded", "charged_credits": 812, "output": {"output": "SUBJECT: ...\n--\nLINE: ...\n--\n"}}}
{"ok": false, "error": {"code": "INSUFFICIENT_CREDITS", "message": "balance below the minimum for this run", "details": {}}}Error codes
| HTTP | code | what it means, and what to do |
|---|---|---|
| 400 | VALIDATION_ERROR |
The request body was rejected. Note that /estimate will not raise this — see step 4. |
| 401 | UNAUTHORIZED |
Missing, malformed or expired bearer token. Mint a new guest token, or sign in again. |
| 402 | INSUFFICIENT_CREDITS |
The wallet is below the hold this run needs. A guest token always fails here; a run needs a personal token. |
| 404 | NOT_FOUND |
No such path, or no such job id for this subject. |
| 409 | CONFLICT |
An Idempotency-Key was reused with a different body. Reuse the key only for a byte-identical retry. |
| 429 | RATE_LIMITED |
Back off and retry with a widening delay. |
| 500 | INTERNAL |
Server-side fault. Safe to retry with the same idempotency key. |
| 503 | UNAVAILABLE |
Capacity or an upstream model is unavailable. Retry after a pause. |
The request body
There is no task field and there is no input wrapper.
The body of /run, /run-stream and /estimate is the
input object, flat, at the top level.
Wrapping it — posting {"input": {...}} — is the failure that costs you
a run and looks like a success. The call returns 200, a job is created, credits are
charged, and every field is silently hidden from the model, which then writes about nothing in
particular. Post the fields at the top level.
| field | type | required | meaning |
|---|---|---|---|
subject | string | yes, non-empty | What the aphorisms are about. A situation reads better than an abstract noun. |
stance | string | yes, may be "" |
The position the whole batch should argue. Empty means the batch is free to stay open. |
register | string | yes | One of wry, austere, warm, provocative. |
count | number | yes | How many aphorisms: 4, 6 or 8. |
moves | string | yes | "mixed", or exactly one of compression, inversion,
definition, concession, measure, absence. |
avoid | string | yes, may be "" |
Words, phrasings or ideas to keep out of the batch. |
crowded_count | number | yes | How many corpus sayings already touch this subject. The page computes it in the browser before the run; over the API you supply it. |
crowding_note | string | yes, may be "" |
A plain-language reading of that number, handed to the model as context. |
Every string field must be present and be a string; every number field must be a finite number. A missing key is not the same as an empty string, and the browser app refuses to send either mistake. The worked example below is used verbatim by every code sample on this page.
{
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
}The reply
The model answers in plain text. Not JSON, not Markdown, no code fences. It is three header fields, then one block per aphorism fenced by lines containing only two dashes, then an optional closing note. Consecutive blocks share the separator line between them, so a batch of six has seven separator lines and not twelve.
SUBJECT: <subject as understood>
REGISTER: <the register asked for>
STANCE: <the position the batch takes, or the word: open>
--
LINE: <the aphorism>
MOVE: <move id or name>
WHY: <what makes it work - may run over several lines without repeating a label>
--
LINE: <the next aphorism>
MOVE: <move id or name>
WHY: <...>
--
NOTES: <craft caveats, or the reason a batch is short>A real reply, lightly shortened:
SUBJECT: keeping a promise you made when things were easier
REGISTER: wry
STANCE: that keeping it is a different act from making it
--
LINE: A promise is a loan taken out against a self you have not met.
MOVE: definition
WHY: It reframes the promise as debt and the future self as the party who pays.
The wryness sits in "have not met" - the debtor cannot be consulted, and was
never asked.
--
LINE: Nobody keeps a promise. They keep paying one.
MOVE: inversion
WHY: One verb does all the work. Keeping sounds like storage; paying admits a cost
that recurs.
--
NOTES: Four rather than six. Two drafts restated the first line in other clothes
and were cut rather than padded out.Four rules the parser leans on, and which a reply is expected to honour:
- Labels start at the beginning of a line. A colon in the middle of a sentence is prose, not a field.
- A second
LINE:inside an already-open block starts a new block, whether or not a separator arrived. That is what makes a dropped separator survivable. - The aphorism itself is never wrapped in quotation marks. If quotation marks turn up, strip them — and treat their presence as a craft signal, not as nothing.
- A short batch is legal as long as
NOTES:says why. Four good lines with a reason beat six with two of them padding, and the app does not treat the shortfall as an error.
Only WHY and NOTES are documented as running over several lines. An
unlabelled, non-empty line is a continuation of whichever of those is currently open.
The two rules that define the app
These are not stylistic preferences. They are the reason the app exists, and both apply to
the whole reply — to LINE, MOVE, WHY,
STANCE and NOTES alike, not just to the aphorisms.
- Nothing is attributed to anybody. Not a real person, not an invented tradition, not a quoted source, not a dash and a plausible name. A quotation with somebody's name on it that they never said is a fake historical record, and it outlives the tool that made it.
- Nothing may reproduce a saying that already exists. The browser matches every field of every reply against a corpus of 1,106 sayings already in circulation, using exact, normalised, reordered and distinctive-word-run comparison, and shows you any hit beside the line that triggered it rather than swallowing it.
What that check has actually been measured to do
The figures, stated plainly, because the shape of the gap matters more than the headline:
- 100% of corpus entries caught when reproduced word for word.
- 84% to 99% caught when reworded, reordered, re-inflected or embedded in a longer sentence. The spread is the honest part: how much survives depends on how much of the distinctive wording survives.
- 3.4% of well-known sayings the corpus does NOT hold were flagged. Those are not errors in the ordinary sense; they are the check doing what it does.
- 0% false-positive rate measured on 50 lines written fresh.
Read that last pair together. This is a corpus-membership test, not an originality test. It answers one question: is this line, or something close enough to it, in the 1,106 sayings the app ships with? A clean result means no known match was found. It does not mean the line is original, because the corpus is not the set of all sayings ever written and never could be.
Over the API you do not even get that much, since the check runs in the browser. If originality matters to what you are building, run the check yourself — and still treat a clean result as the absence of evidence rather than as evidence of absence.
1. A tiny client
Everything after this reuses one helper: send JSON, read the envelope, raise on
ok: false, hand back data. Eighteen lines in most languages, and it
removes the two mistakes that cost the most — reading error off a
200, and reading fields off the envelope instead of off data.
# Two shell helpers. Everything below reuses them.
export SKILLSAFE_APP_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
forge_post() { # forge_post <path> <json-body>
curl -s -X POST "$BASE/$1" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
}
forge_get() { # forge_get <path>
curl -s "$BASE/$1" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN"
}
# Unwrap the envelope and fail loudly on {"ok":false,...}
forge_data() {
jq -e 'if .ok == false then error(.error.code + ": " + .error.message) else .data end'
}import os, requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_APP_TOKEN", "YOUR_TOKEN")
class ForgeError(RuntimeError):
def __init__(self, code, message):
super().__init__(code + ": " + message)
self.code = code
# Every response is {"ok":true,"data":...} or {"ok":false,"error":{...}}.
def forge(path, body=None, method=None):
r = requests.request(method or ("POST" if body is not None else "GET"),
BASE + path, timeout=180, json=body,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"})
env = r.json()
if env.get("ok") is False:
err = env.get("error") or {}
raise ForgeError(err.get("code", "INTERNAL"), err.get("message", "request failed"))
return env["data"]const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
class ForgeError extends Error {
constructor(code, message) {
super(code + ": " + message);
this.code = code;
}
}
// Every response is {"ok":true,"data":...} or {"ok":false,"error":{...}}.
async function forge(path, body, method) {
const res = await fetch(BASE + path, {
method: method || (body ? "POST" : "GET"),
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: body ? JSON.stringify(body) : undefined
});
const env = await res.json();
if (env.ok === false) throw new ForgeError(env.error.code, env.error.message);
return env.data;
}package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// forge returns the raw data member; decode it into whatever shape you expect.
func forge(method, path string, body any) (json.RawMessage, error) {
r := bytes.NewReader(nil)
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, r)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_APP_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var env envelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}import java.net.URI;
import java.net.http.*;
class Forge {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_APP_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
// Returns the whole envelope as text: {"ok":true,"data":{...}}
static String call(String method, String path, String body) throws Exception {
HttpRequest.BodyPublisher pub = (body == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub)
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException("app-api " + res.statusCode() + ": " + res.body());
}
return res.body();
}
}require 'net/http'
require 'json'
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["SKILLSAFE_APP_TOKEN"]
ForgeError = Class.new(StandardError)
# Every response is {"ok":true,"data":...} or {"ok":false,"error":{...}}.
def forge(path, body = nil, method = nil)
uri = URI(BASE + path)
verb = method || (body.nil? ? "GET" : "POST")
req = (verb == "GET" ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) unless body.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
if env["ok"] == false
raise ForgeError, "#{env['error']['code']}: #{env['error']['message']}"
end
env["data"]
end<?php
const FORGE_BASE = "https://api.skillsafe.ai/v1/app-api";
// Every response is {"ok":true,"data":...} or {"ok":false,"error":{...}}.
function forge(string $path, $body = null, string $method = null) {
$token = getenv("SKILLSAFE_APP_TOKEN");
$verb = $method ?? ($body === null ? "GET" : "POST");
$opts = ["http" => [
"method" => $verb,
"header" => "Authorization: Bearer $token\r\nContent-Type: application/json\r\n",
"ignore_errors" => true,
"timeout" => 180,
]];
if ($body !== null) {
$opts["http"]["content"] = json_encode($body);
}
$raw = file_get_contents(FORGE_BASE . $path, false, stream_context_create($opts));
$env = json_decode($raw, true);
if (isset($env["ok"]) && $env["ok"] === false) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class Forge {
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Client = new HttpClient();
// Every response is {"ok":true,"data":...} or {"ok":false,"error":{...}}.
public static async Task<JsonElement> Call(HttpMethod method, string path, string body = null) {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_APP_TOKEN");
using var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body != null) {
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (env.TryGetProperty("error", out var e)) {
throw new Exception(e.GetProperty("code").GetString() + ": " +
e.GetProperty("message").GetString());
}
return env.GetProperty("data");
}
}2. A token
Every call carries a bearer token. A guest token is free, needs no account, is
minted by one unauthenticated call, and is enough for /me and /estimate.
Writing a batch is metered, so /run and /run-stream need a
personal token, which comes from signing in.
If you would rather not script the sign-in, the token page reads the
token this browser already holds for quote-generator, shows you whose it is, and copies
it or a ready-made shell export to the clipboard.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug": "quote-generator"}'import os, requests
TOKEN = os.environ.get("SKILLSAFE_APP_TOKEN", "YOUR_TOKEN")
headers = {"Authorization": f"Bearer {TOKEN}"}
r = requests.post("https://api.skillsafe.ai/v1/app-api/guest", headers=headers,
json={
"slug": "quote-generator"
}, timeout=120)
r.raise_for_status()
print(r.json()["data"])const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"slug": "quote-generator"
})
});
const { data } = await res.json();
console.log(data);package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := os.Getenv("SKILLSAFE_APP_TOKEN")
body := []byte(`{"slug": "quote-generator"}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b))
}import java.net.URI;
import java.net.http.*;
class Main {
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_APP_TOKEN");
String body = """
{
"slug": "quote-generator"
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
}
}require 'net/http'
require 'json'
token = ENV["SKILLSAFE_APP_TOKEN"]
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = '{"slug": "quote-generator"}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]<?php
$token = getenv("SKILLSAFE_APP_TOKEN");
$opts = ["http" => [
"method" => "POST",
"header" => "Authorization: Bearer $token\r\nContent-Type: application/json\r\n",
'content' => '{"slug": "quote-generator"}',
]];
$res = file_get_contents("https://api.skillsafe.ai/v1/app-api/guest", false, stream_context_create($opts));
print_r(json_decode($res, true)["data"]);using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = Environment.GetEnvironmentVariable("SKILLSAFE_APP_TOKEN");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var body = new StringContent(@"{""slug"": ""quote-generator""}", Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", body);
Console.WriteLine(await res.Content.ReadAsStringAsync());3. Who the token belongs to
/me returns exactly three fields: subject_type, subject_id
and credits. There is no email, no display name and no id beyond the subject id, so
the only test for "signed in" is subject_type === "user". Anything else is a guest,
and a guest cannot spend.
curl -s "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN"import os, requests
TOKEN = os.environ.get("SKILLSAFE_APP_TOKEN", "YOUR_TOKEN")
headers = {"Authorization": f"Bearer {TOKEN}"}
r = requests.get("https://api.skillsafe.ai/v1/app-api/me", headers=headers, timeout=120)
r.raise_for_status()
print(r.json()["data"])const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
}
});
const { data } = await res.json();
console.log(data);package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := os.Getenv("SKILLSAFE_APP_TOKEN")
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b))
}import java.net.URI;
import java.net.http.*;
class Main {
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_APP_TOKEN");
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.GET()
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
}
}require 'net/http'
require 'json'
token = ENV["SKILLSAFE_APP_TOKEN"]
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]<?php
$token = getenv("SKILLSAFE_APP_TOKEN");
$opts = ["http" => [
"method" => "GET",
"header" => "Authorization: Bearer $token\r\nContent-Type: application/json\r\n",
]];
$res = file_get_contents("https://api.skillsafe.ai/v1/app-api/me", false, stream_context_create($opts));
print_r(json_decode($res, true)["data"]);using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = Environment.GetEnvironmentVariable("SKILLSAFE_APP_TOKEN");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var res = await client.GetAsync("https://api.skillsafe.ai/v1/app-api/me");
Console.WriteLine(await res.Content.ReadAsStringAsync());4. Pricing a batch
/estimate costs nothing, creates no job, and returns the credit hold a run with this
body would place. You are charged for what the run actually consumes, which is normally well under
the hold.
The trap, and it is worth acting on. /estimate performs
no validation of the request body at all. A bare string, a number, null,
[] and a correctly shaped object all return ok: true, with a
well-formed hold and a correct model binding. A malformed body and a right one are
indistinguishable from the response.
So a successful estimate tells you nothing whatever about whether your input was shaped right,
and the model-binding assertion — the check most likely to be mistaken for proof —
passes either way. Validate on your side before you send. This app routes every path that
spends, /estimate and /run alike, through one
mustBeObject() guard that asserts each string field is a string and each number
field is a finite number, for exactly this reason.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}'import os, requests
TOKEN = os.environ.get("SKILLSAFE_APP_TOKEN", "YOUR_TOKEN")
headers = {"Authorization": f"Bearer {TOKEN}"}
r = requests.post("https://api.skillsafe.ai/v1/app-api/estimate", headers=headers,
json={
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
}, timeout=120)
r.raise_for_status()
print(r.json()["data"])const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
})
});
const { data } = await res.json();
console.log(data);package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := os.Getenv("SKILLSAFE_APP_TOKEN")
body := []byte(`{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b))
}import java.net.URI;
import java.net.http.*;
class Main {
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_APP_TOKEN");
String body = """
{
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
}
}require 'net/http'
require 'json'
token = ENV["SKILLSAFE_APP_TOKEN"]
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = '{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]<?php
$token = getenv("SKILLSAFE_APP_TOKEN");
$opts = ["http" => [
"method" => "POST",
"header" => "Authorization: Bearer $token\r\nContent-Type: application/json\r\n",
'content' => '{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}',
]];
$res = file_get_contents("https://api.skillsafe.ai/v1/app-api/estimate", false, stream_context_create($opts));
print_r(json_decode($res, true)["data"]);using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = Environment.GetEnvironmentVariable("SKILLSAFE_APP_TOKEN");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var body = new StringContent(@"{""subject"": ""keeping a promise you made when things were easier"", ""stance"": ""that keeping it is a different act from making it"", ""register"": ""wry"", ""count"": 6, ""moves"": ""mixed"", ""avoid"": ""loyalty, integrity, character"", ""crowded_count"": 52, ""crowding_note"": ""52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said.""}", Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/estimate", body);
Console.WriteLine(await res.Content.ReadAsStringAsync());5. Writing a batch
/run takes the same body and returns as soon as the job is queued — you get a
job_id, not the text. Pass an Idempotency-Key header if you may retry:
a repeat with the same key and the same body replays the original job instead of charging twice,
and the same key with a different body is a 409 CONFLICT.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}'import os, requests
TOKEN = os.environ.get("SKILLSAFE_APP_TOKEN", "YOUR_TOKEN")
headers = {"Authorization": f"Bearer {TOKEN}"}
r = requests.post("https://api.skillsafe.ai/v1/app-api/run", headers=headers,
json={
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
}, timeout=120)
r.raise_for_status()
print(r.json()["data"])const TOKEN = "YOUR_TOKEN";
const res = await fetch("https://api.skillsafe.ai/v1/app-api/run", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
})
});
const { data } = await res.json();
console.log(data);package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := os.Getenv("SKILLSAFE_APP_TOKEN")
body := []byte(`{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b))
}import java.net.URI;
import java.net.http.*;
class Main {
public static void main(String[] args) throws Exception {
String token = System.getenv("SKILLSAFE_APP_TOKEN");
String body = """
{
"subject": "keeping a promise you made when things were easier",
"stance": "that keeping it is a different act from making it",
"register": "wry",
"count": 5,
"moves": "mixed",
"avoid": "loyalty, integrity, character",
"crowded_count": 52,
"crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/run"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
}
}require 'net/http'
require 'json'
token = ENV["SKILLSAFE_APP_TOKEN"]
uri = URI("https://api.skillsafe.ai/v1/app-api/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = '{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]<?php
$token = getenv("SKILLSAFE_APP_TOKEN");
$opts = ["http" => [
"method" => "POST",
"header" => "Authorization: Bearer $token\r\nContent-Type: application/json\r\n",
'content' => '{"subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said."}',
]];
$res = file_get_contents("https://api.skillsafe.ai/v1/app-api/run", false, stream_context_create($opts));
print_r(json_decode($res, true)["data"]);using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = Environment.GetEnvironmentVariable("SKILLSAFE_APP_TOKEN");
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var body = new StringContent(@"{""subject"": ""keeping a promise you made when things were easier"", ""stance"": ""that keeping it is a different act from making it"", ""register"": ""wry"", ""count"": 6, ""moves"": ""mixed"", ""avoid"": ""loyalty, integrity, character"", ""crowded_count"": 52, ""crowding_note"": ""52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said.""}", Encoding.UTF8, "application/json");
var res = await client.PostAsync("https://api.skillsafe.ai/v1/app-api/run", body);
Console.WriteLine(await res.Content.ReadAsStringAsync());6. Waiting for it
Poll GET /jobs/{job_id} until status is succeeded or
failed; anything else means keep waiting. A second between polls and a three-minute
ceiling is what the app itself uses. On success the whole plain-text reply is one string at
data.output.output — note the doubled key, which is the field
output inside the object output.
# /run answers with a job id long before the batch is written.
JOB=$(forge_post run "$INPUT" | jq -r '.data.job_id')
while :; do
STATUS=$(forge_get "jobs/$JOB" | jq -r '.data.status')
case "$STATUS" in succeeded|failed) break ;; esac
sleep 1
done
# The whole reply is one plain-text string under data.output.output.
forge_get "jobs/$JOB" | jq -r '.data.output.output'import time
def wait_for(job_id, interval=1.0, timeout=180.0):
deadline = time.time() + timeout
while True:
job = forge("/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
return job
if time.time() > deadline:
raise TimeoutError("job " + job_id + " did not settle in time")
time.sleep(interval)
started = forge("/run", INPUT)
job = wait_for(started["job_id"])
print(job["status"], job.get("charged_credits"))
print(job["output"]["output"]) # the plain-text replyconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(jobId, intervalMs = 1000, timeoutMs = 180000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const job = await forge("/jobs/" + encodeURIComponent(jobId));
if (job.status === "succeeded" || job.status === "failed") return job;
if (Date.now() > deadline) throw new Error("job " + jobId + " did not settle in time");
await sleep(intervalMs);
}
}
const started = await forge("/run", INPUT);
const job = await waitFor(started.job_id);
console.log(job.status, job.charged_credits);
console.log(job.output.output); // the plain-text replyimport (
"encoding/json"
"errors"
"time"
)
type jobView struct {
Status string `json:"status"`
ChargedCredits int `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
func waitFor(jobID string) (jobView, error) {
deadline := time.Now().Add(3 * time.Minute)
for {
raw, err := forge("GET", "/jobs/"+jobID, nil)
if err != nil {
return jobView{}, err
}
var job jobView
if err := json.Unmarshal(raw, &job); err != nil {
return jobView{}, err
}
if job.Status == "succeeded" || job.Status == "failed" {
return job, nil
}
if time.Now().After(deadline) {
return jobView{}, errors.New("job " + jobID + " did not settle in time")
}
time.Sleep(time.Second)
}
}// A substring test keeps the sample dependency-free; use your JSON library for real.
static String waitFor(String jobId) throws Exception {
long deadline = System.currentTimeMillis() + 180_000L;
while (true) {
String env = Forge.call("GET", "/jobs/" + jobId, null);
if (env.contains("\"status\":\"succeeded\"") || env.contains("\"status\":\"failed\"")) {
return env; // {"ok":true,"data":{"status":"...","output":{"output":"..."}}}
}
if (System.currentTimeMillis() > deadline) {
throw new RuntimeException("job " + jobId + " did not settle in time");
}
Thread.sleep(1000L);
}
}def wait_for(job_id, interval: 1, timeout: 180)
deadline = Time.now + timeout
loop do
job = forge("/jobs/#{job_id}")
return job if %w[succeeded failed].include?(job["status"])
raise ForgeError, "job #{job_id} did not settle in time" if Time.now > deadline
sleep interval
end
end
started = forge("/run", INPUT)
job = wait_for(started["job_id"])
puts "#{job['status']} #{job['charged_credits']}"
puts job["output"]["output"] # the plain-text reply<?php
function wait_for(string $jobId, int $timeout = 180) {
$deadline = time() + $timeout;
while (true) {
$job = forge("/jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) {
return $job;
}
if (time() > $deadline) {
throw new RuntimeException("job $jobId did not settle in time");
}
sleep(1);
}
}
$started = forge("/run", $input);
$job = wait_for($started["job_id"]);
echo $job["status"], " ", $job["charged_credits"], "\n";
echo $job["output"]["output"], "\n"; // the plain-text replystatic async Task<JsonElement> WaitFor(string jobId) {
var deadline = DateTime.UtcNow.AddMinutes(3);
while (true) {
var job = await Forge.Call(HttpMethod.Get, "/jobs/" + Uri.EscapeDataString(jobId));
var status = job.GetProperty("status").GetString();
if (status == "succeeded" || status == "failed") return job;
if (DateTime.UtcNow > deadline) {
throw new Exception("job " + jobId + " did not settle in time");
}
await Task.Delay(1000);
}
}
var started = await Forge.Call(HttpMethod.Post, "/run", inputJson);
var job = await WaitFor(started.GetProperty("job_id").GetString());
Console.WriteLine(job.GetProperty("output").GetProperty("output").GetString());7. Streaming
/run-stream sends the same run as server-sent events, so you can show the batch
arriving instead of a spinner. Frames are separated by a blank line, each carries an
event: name and a single-line JSON data: payload, and the four names
that matter are delta, job, done and error.
The delta texts concatenate to exactly the string that arrives in
done.output.output, so streaming changes when you see the text and nothing about what
it is. An idempotent replay may answer with a plain JSON body rather than an event stream; check
the response content type before you start reading frames.
curl -N -s -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_APP_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# Frames are separated by a blank line. Four event names matter:
#
# event: delta
# data: {"text": "LINE: A promise is a loan against a self you have not met.\n"}
#
# event: job
# data: {"job_id": "job_...", "status": "running"}
#
# event: done
# data: {"job_id": "job_...", "status": "succeeded", "charged_credits": 812,
# "output": {"output": "SUBJECT: ...\n--\nLINE: ...\n--\n"}}
#
# event: error
# data: {"code": "INSUFFICIENT_CREDITS", "message": "balance below the run minimum"}
#
# The deltas concatenate to exactly the string in done.output.output, so you can
# parse incrementally or wait for the whole thing. Both give the same blocks.import json, requests
def run_stream(body, on_delta):
r = requests.post(BASE + "/run-stream", stream=True, timeout=300, json=body,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Accept": "text/event-stream"})
r.raise_for_status()
event, payload = "message", ""
for raw in r.iter_lines(decode_unicode=True):
line = raw if raw is not None else ""
if line == "":
if payload:
data = json.loads(payload)
if event == "delta":
on_delta(data.get("text", ""))
elif event == "done":
return data
elif event == "error":
raise ForgeError(data.get("code", "INTERNAL"), data.get("message", ""))
event, payload = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload += line[5:].strip()
return None
chunks = []
done = run_stream(INPUT, chunks.append)
assert "".join(chunks) == done["output"]["output"]async function runStream(body, onDelta) {
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Accept": "text/event-stream"
},
body: JSON.stringify(body)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
let event = "message";
let payload = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) payload += line.slice(5).trim();
}
if (!payload) continue;
const data = JSON.parse(payload);
if (event === "delta") onDelta(data.text || "");
else if (event === "done") done = data;
else if (event === "error") throw new ForgeError(data.code, data.message);
}
}
return done;
}
let seen = "";
const done = await runStream(INPUT, (t) => { seen += t; });
console.log(seen === done.output.output); // trueimport (
"bufio"
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
)
func runStream(body any, onDelta func(string)) (json.RawMessage, error) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_APP_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
event, payload := "message", ""
for sc.Scan() {
line := sc.Text()
switch {
case line == "":
if payload != "" {
var d struct {
Text string `json:"text"`
Code string `json:"code"`
Message string `json:"message"`
Raw json.RawMessage `json:"-"`
}
json.Unmarshal([]byte(payload), &d)
if event == "delta" {
onDelta(d.Text)
} else if event == "done" {
return json.RawMessage(payload), nil
} else if event == "error" {
return nil, errors.New(d.Code + ": " + d.Message)
}
}
event, payload = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
payload += strings.TrimSpace(line[5:])
}
}
return nil, sc.Err()
}import java.net.URI;
import java.net.http.*;
import java.util.function.Consumer;
static String runStream(String body, Consumer<String> onDelta) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(Forge.BASE + "/run-stream"))
.header("Authorization", "Bearer " + Forge.TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
Forge.HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
StringBuilder payload = new StringBuilder();
String[] event = { "message" };
String[] done = { null };
res.body().forEach(line -> {
if (line.isEmpty()) {
if (payload.length() > 0) {
if (event[0].equals("delta")) onDelta.accept(payload.toString());
else if (event[0].equals("done")) done[0] = payload.toString();
}
payload.setLength(0);
event[0] = "message";
} else if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:")) {
payload.append(line.substring(5).trim());
}
});
return done[0]; // the done frame, JSON; the text is at output.output
}require 'net/http'
require 'json'
def run_stream(body)
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req.body = JSON.dump(body)
done = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
buffer = +""
event = "message"
payload = +""
res.read_body do |chunk|
buffer << chunk
while (i = buffer.index("\n"))
line = buffer.slice!(0, i + 1).chomp
if line.empty?
unless payload.empty?
data = JSON.parse(payload)
yield data["text"].to_s if event == "delta" && block_given?
done = data if event == "done"
raise ForgeError, "#{data['code']}: #{data['message']}" if event == "error"
end
event = "message"
payload = +""
elsif line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
payload << line[5..].strip
end
end
end
end
end
done
end<?php
function run_stream(array $body, callable $onDelta) {
$token = getenv("SKILLSAFE_APP_TOKEN");
$opts = ["http" => [
"method" => "POST",
"header" => "Authorization: Bearer $token\r\n" .
"Content-Type: application/json\r\n" .
"Accept: text/event-stream\r\n",
"content" => json_encode($body),
"timeout" => 300,
]];
$stream = fopen(FORGE_BASE . "/run-stream", "r", false, stream_context_create($opts));
$event = "message";
$payload = "";
$done = null;
while (($line = fgets($stream)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === "") {
if ($payload !== "") {
$data = json_decode($payload, true);
if ($event === "delta") { $onDelta($data["text"] ?? ""); }
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") {
throw new RuntimeException($data["code"] . ": " . $data["message"]);
}
}
$event = "message";
$payload = "";
} elseif (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$payload .= trim(substr($line, 5));
}
}
fclose($stream);
return $done;
}static async Task<JsonElement?> RunStream(string body, Action<string> onDelta) {
var token = Environment.GetEnvironmentVariable("SKILLSAFE_APP_TOKEN");
using var req = new HttpRequestMessage(HttpMethod.Post, Forge.Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await Forge.Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var evt = "message";
var payload = new StringBuilder();
JsonElement? done = null;
string line;
while ((line = await reader.ReadLineAsync()) != null) {
if (line.Length == 0) {
if (payload.Length > 0) {
var data = JsonDocument.Parse(payload.ToString()).RootElement;
if (evt == "delta") onDelta(data.GetProperty("text").GetString());
else if (evt == "done") done = data.Clone();
else if (evt == "error") {
throw new Exception(data.GetProperty("code").GetString());
}
}
payload.Clear();
evt = "message";
} else if (line.StartsWith("event:")) {
evt = line.Substring(6).Trim();
} else if (line.StartsWith("data:")) {
payload.Append(line.Substring(5).Trim());
}
}
return done;
}8. Reading the reply
The last step is the one people skip: the reply is text, so you need a parser. It is a small one.
Walk the lines; a line that is nothing but two or more dashes closes the open block and opens the
next; a line starting LINE:, MOVE: or WHY: sets that field;
an unlabelled non-empty line extends whichever field is still open. Header fields
(SUBJECT, REGISTER, STANCE) come before the first separator,
and NOTES: comes after the last one.
Two details save the awkward cases. A second LINE: while a block is already open
starts a new block, so a missing separator loses formatting rather than losing an aphorism. And a
block is only worth keeping if it has a LINE or a WHY, which is what
stops a trailing separator producing an empty final entry.
# The reply is one string. Split it on the two-dash separator lines and read
# the labels off the front of each line. awk is enough.
forge_get "jobs/$JOB" | jq -r '.data.output.output' | awk '
/^[ \t]*--+[ \t]*$/ {
if (line != "") { n++; printf "%d. %s\n move: %s\n why: %s\n", n, line, move, why }
line = ""; move = ""; why = ""; field = ""; next
}
/^[ \t]*LINE[ \t]*:/ { if (line != "") { n++; printf "%d. %s\n", n, line }
line = $0; sub(/^[ \t]*LINE[ \t]*:[ \t]*/, "", line); field = "line"; next }
/^[ \t]*MOVE[ \t]*:/ { move = $0; sub(/^[ \t]*MOVE[ \t]*:[ \t]*/, "", move); field = "move"; next }
/^[ \t]*WHY[ \t]*:/ { why = $0; sub(/^[ \t]*WHY[ \t]*:[ \t]*/, "", why); field = "why"; next }
/^[ \t]*NOTES?[ \t]*:/ { notes = $0; sub(/^[ \t]*NOTES?[ \t]*:[ \t]*/, "", notes); field = "notes"; next }
/^[ \t]*(SUBJECT|REGISTER|STANCE)[ \t]*:/ { print; field = ""; next }
# An unlabelled line continues whichever field is open - usually WHY.
{ if (field == "why" && $0 != "") why = why " " $0 }
END {
if (line != "") { n++; printf "%d. %s\n move: %s\n why: %s\n", n, line, move, why }
if (notes != "") printf "NOTES: %s\n", notes
}
'import re
SEP = re.compile(r"^\s*-{2,}\s*$")
LABEL = re.compile(r"^\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\s*:\s*(.*)$", re.I)
def parse_reply(text):
out = {"subject": "", "register": "", "stance": "", "blocks": [], "notes": ""}
block, field = None, None
def fresh():
return {"line": "", "move": "", "why": ""}
def flush():
nonlocal block
if block and (block["line"] or block["why"]):
out["blocks"].append(block)
block = None
for raw in str(text).replace("\r\n", "\n").split("\n"):
if SEP.match(raw):
flush()
block, field = fresh(), None
continue
m = LABEL.match(raw)
if m:
key, val = m.group(1).upper(), m.group(2).strip()
if key == "LINE":
if block and block["line"]:
flush()
if block is None:
block = fresh()
block["line"], field = val, "line"
elif key in ("MOVE", "WHY"):
if block is None:
block = fresh()
block[key.lower()], field = val, key.lower()
elif key.startswith("NOTE"):
flush()
out["notes"], field = val, "notes"
else:
out[key.lower()], field = val, None
continue
t = raw.strip()
if not t:
continue
if field == "notes":
out["notes"] += " " + t
elif block and field:
block[field] += " " + t
flush()
return out
reply = parse_reply(job["output"]["output"])
for i, b in enumerate(reply["blocks"], 1):
print(i, b["line"], "|", b["move"])const SEP = /^\s*-{2,}\s*$/;
const LABEL = /^\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\s*:\s*(.*)$/i;
function parseReply(text) {
const out = { subject: "", register: "", stance: "", blocks: [], notes: "" };
const fresh = () => ({ line: "", move: "", why: "" });
let block = null;
let field = null;
const flush = () => {
if (block && (block.line || block.why)) out.blocks.push(block);
block = null;
};
for (const raw of String(text).split(/\r?\n/)) {
if (SEP.test(raw)) { flush(); block = fresh(); field = null; continue; }
const m = LABEL.exec(raw);
if (m) {
const key = m[1].toUpperCase();
const val = m[2].trim();
if (key === "LINE") {
if (block && block.line) flush();
if (!block) block = fresh();
block.line = val; field = "line";
} else if (key === "MOVE" || key === "WHY") {
if (!block) block = fresh();
block[key.toLowerCase()] = val; field = key.toLowerCase();
} else if (key.indexOf("NOTE") === 0) {
flush(); out.notes = val; field = "notes";
} else {
out[key.toLowerCase()] = val; field = null;
}
continue;
}
const t = raw.trim();
if (!t) continue;
if (field === "notes") out.notes += " " + t;
else if (block && field) block[field] += " " + t;
}
flush();
return out;
}
const reply = parseReply(job.output.output);
reply.blocks.forEach((b, i) => console.log(i + 1, b.line, "|", b.move));import (
"regexp"
"strings"
)
type Block struct{ Line, Move, Why string }
type Reply struct {
Subject, Register, Stance, Notes string
Blocks []Block
}
var sepRe = regexp.MustCompile(`^\s*-{2,}\s*$`)
var labelRe = regexp.MustCompile(`(?i)^\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\s*:\s*(.*)$`)
func ParseReply(text string) Reply {
out := Reply{}
var block *Block
field := ""
flush := func() {
if block != nil && (block.Line != "" || block.Why != "") {
out.Blocks = append(out.Blocks, *block)
}
block = nil
}
for _, raw := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
if sepRe.MatchString(raw) {
flush()
block, field = &Block{}, ""
continue
}
if m := labelRe.FindStringSubmatch(raw); m != nil {
key, val := strings.ToUpper(m[1]), strings.TrimSpace(m[2])
switch {
case key == "LINE":
if block != nil && block.Line != "" {
flush()
}
if block == nil {
block = &Block{}
}
block.Line, field = val, "line"
case key == "MOVE":
if block == nil {
block = &Block{}
}
block.Move, field = val, "move"
case key == "WHY":
if block == nil {
block = &Block{}
}
block.Why, field = val, "why"
case strings.HasPrefix(key, "NOTE"):
flush()
out.Notes, field = val, "notes"
case key == "SUBJECT":
out.Subject, field = val, ""
case key == "REGISTER":
out.Register, field = val, ""
case key == "STANCE":
out.Stance, field = val, ""
}
continue
}
t := strings.TrimSpace(raw)
if t == "" {
continue
}
switch field {
case "notes":
out.Notes += " " + t
case "line":
block.Line += " " + t
case "move":
block.Move += " " + t
case "why":
block.Why += " " + t
}
}
flush()
return out
}import java.util.*;
import java.util.regex.*;
class Block { String line = "", move = "", why = ""; }
class Reply {
String subject = "", register = "", stance = "", notes = "";
List<Block> blocks = new ArrayList<>();
}
static final Pattern SEP = Pattern.compile("^\\s*-{2,}\\s*$");
static final Pattern LABEL = Pattern.compile(
"^\\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\\s*:\\s*(.*)$",
Pattern.CASE_INSENSITIVE);
static Reply parseReply(String text) {
Reply out = new Reply();
Block[] block = { null };
String field = "";
Runnable flush = () -> {
if (block[0] != null && (!block[0].line.isEmpty() || !block[0].why.isEmpty())) {
out.blocks.add(block[0]);
}
block[0] = null;
};
for (String raw : text.replace("\r\n", "\n").split("\n", -1)) {
if (SEP.matcher(raw).matches()) {
flush.run();
block[0] = new Block();
field = "";
continue;
}
Matcher m = LABEL.matcher(raw);
if (m.matches()) {
String key = m.group(1).toUpperCase(Locale.ROOT);
String val = m.group(2).trim();
if (key.equals("LINE")) {
if (block[0] != null && !block[0].line.isEmpty()) flush.run();
if (block[0] == null) block[0] = new Block();
block[0].line = val; field = "line";
} else if (key.equals("MOVE")) {
if (block[0] == null) block[0] = new Block();
block[0].move = val; field = "move";
} else if (key.equals("WHY")) {
if (block[0] == null) block[0] = new Block();
block[0].why = val; field = "why";
} else if (key.startsWith("NOTE")) {
flush.run();
out.notes = val; field = "notes";
} else if (key.equals("SUBJECT")) { out.subject = val; field = ""; }
else if (key.equals("REGISTER")) { out.register = val; field = ""; }
else if (key.equals("STANCE")) { out.stance = val; field = ""; }
continue;
}
String t = raw.trim();
if (t.isEmpty()) continue;
if (field.equals("notes")) out.notes += " " + t;
else if (block[0] != null && field.equals("line")) block[0].line += " " + t;
else if (block[0] != null && field.equals("move")) block[0].move += " " + t;
else if (block[0] != null && field.equals("why")) block[0].why += " " + t;
}
flush.run();
return out;
}SEP = /\A\s*-{2,}\s*\z/
LABEL = /\A\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\s*:\s*(.*)\z/i
def parse_reply(text)
out = { "subject" => "", "register" => "", "stance" => "", "blocks" => [], "notes" => "" }
block = nil
field = nil
fresh = -> { { "line" => "", "move" => "", "why" => "" } }
flush = lambda do
out["blocks"] << block if block && (!block["line"].empty? || !block["why"].empty?)
block = nil
end
text.to_s.gsub("\r\n", "\n").split("\n", -1).each do |raw|
if raw =~ SEP
flush.call
block = fresh.call
field = nil
next
end
if (m = LABEL.match(raw))
key = m[1].upcase
val = m[2].strip
case key
when "LINE"
flush.call if block && !block["line"].empty?
block ||= fresh.call
block["line"] = val
field = "line"
when "MOVE", "WHY"
block ||= fresh.call
block[key.downcase] = val
field = key.downcase
when "NOTE", "NOTES"
flush.call
out["notes"] = val
field = "notes"
else
out[key.downcase] = val
field = nil
end
next
end
t = raw.strip
next if t.empty?
if field == "notes"
out["notes"] += " " + t
elsif block && field
block[field] += " " + t
end
end
flush.call
out
end
reply = parse_reply(job["output"]["output"])
reply["blocks"].each_with_index { |b, i| puts "#{i + 1}. #{b['line']} | #{b['move']}" }<?php
const QF_SEP = '/^\s*-{2,}\s*$/';
const QF_LABEL = '/^\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\s*:\s*(.*)$/i';
function parse_reply(string $text): array {
$out = ["subject" => "", "register" => "", "stance" => "", "blocks" => [], "notes" => ""];
$fresh = fn() => ["line" => "", "move" => "", "why" => ""];
$block = null;
$field = null;
$flush = function () use (&$block, &$out) {
if ($block !== null && ($block["line"] !== "" || $block["why"] !== "")) {
$out["blocks"][] = $block;
}
$block = null;
};
foreach (explode("\n", str_replace("\r\n", "\n", $text)) as $raw) {
if (preg_match(QF_SEP, $raw)) {
$flush();
$block = $fresh();
$field = null;
continue;
}
if (preg_match(QF_LABEL, $raw, $m)) {
$key = strtoupper($m[1]);
$val = trim($m[2]);
if ($key === "LINE") {
if ($block !== null && $block["line"] !== "") { $flush(); }
if ($block === null) { $block = $fresh(); }
$block["line"] = $val; $field = "line";
} elseif ($key === "MOVE" || $key === "WHY") {
if ($block === null) { $block = $fresh(); }
$block[strtolower($key)] = $val; $field = strtolower($key);
} elseif (str_starts_with($key, "NOTE")) {
$flush();
$out["notes"] = $val; $field = "notes";
} else {
$out[strtolower($key)] = $val; $field = null;
}
continue;
}
$t = trim($raw);
if ($t === "") { continue; }
if ($field === "notes") { $out["notes"] .= " " . $t; }
elseif ($block !== null && $field !== null) { $block[$field] .= " " . $t; }
}
$flush();
return $out;
}
$reply = parse_reply($job["output"]["output"]);
foreach ($reply["blocks"] as $i => $b) {
echo ($i + 1) . ". " . $b["line"] . " | " . $b["move"] . "\n";
}using System.Text.RegularExpressions;
record Block { public string Line = "", Move = "", Why = ""; }
class Reply {
public string Subject = "", Register = "", Stance = "", Notes = "";
public List<Block> Blocks = new();
}
static readonly Regex Sep = new(@"^\s*-{2,}\s*$");
static readonly Regex Label = new(
@"^\s*(SUBJECT|REGISTER|STANCE|LINE|MOVE|WHY|NOTES?)\s*:\s*(.*)$",
RegexOptions.IgnoreCase);
static Reply ParseReply(string text) {
var outp = new Reply();
Block block = null;
var field = "";
void Flush() {
if (block != null && (block.Line.Length > 0 || block.Why.Length > 0)) {
outp.Blocks.Add(block);
}
block = null;
}
foreach (var raw in text.Replace("\r\n", "\n").Split('\n')) {
if (Sep.IsMatch(raw)) { Flush(); block = new Block(); field = ""; continue; }
var m = Label.Match(raw);
if (m.Success) {
var key = m.Groups[1].Value.ToUpperInvariant();
var val = m.Groups[2].Value.Trim();
if (key == "LINE") {
if (block != null && block.Line.Length > 0) Flush();
block ??= new Block();
block.Line = val; field = "line";
} else if (key == "MOVE") {
block ??= new Block();
block.Move = val; field = "move";
} else if (key == "WHY") {
block ??= new Block();
block.Why = val; field = "why";
} else if (key.StartsWith("NOTE")) {
Flush(); outp.Notes = val; field = "notes";
} else if (key == "SUBJECT") { outp.Subject = val; field = ""; }
else if (key == "REGISTER") { outp.Register = val; field = ""; }
else if (key == "STANCE") { outp.Stance = val; field = ""; }
continue;
}
var t = raw.Trim();
if (t.Length == 0) continue;
if (field == "notes") outp.Notes += " " + t;
else if (block != null && field == "line") block.Line += " " + t;
else if (block != null && field == "move") block.Move += " " + t;
else if (block != null && field == "why") block.Why += " " + t;
}
Flush();
return outp;
}Rate, size and retries
429 RATE_LIMITEDis the signal to back off. Widen the delay on each retry rather than hammering a fixed interval.- Retries of a spending call should carry an
Idempotency-Key. Without one, a retry is a second run and a second charge. countis capped at 8. Ask for two batches rather than one long one; a batch of eight already tends to restate itself, which is exactly what the craft check reports.- A run that ends
failedstill returns a job record. Readstatusbefore you readoutput, and be ready foroutputto be absent. - A truncated stream is still worth parsing. Every complete block before the cut survives the parser above, so a stopped run shows four of six lines rather than an error.