Drive Changelog Desk from your own code
The web page's two paid lanes are available over HTTP. You send one JSON object with a
task (review or draft) and a facts string. The
model replies with one JSON object: a verdict, findings and rewrites for a review, or the next
release's entries for a draft.
The model does not parse your changelog or your commits. The page does that in your browser, in
clkit.js: it reads the file, raises findings, reads the commits and works out the
next version. Then it sends the result as facts. When you call the API directly
there is no page to do this, so you build facts yourself, in the same
shape (see The facts string).
Base URL and the envelope
Every endpoint below is under https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer YOUR_TOKEN. The SDK leaves the header out when it has no token.
HTTP 2xx { "data": { ... } }
non-2xx { "error": { "code": "...", "message": "...", "details": { ... } } }
This is how the vendored SDK (sdk.js) reads a response. It checks the HTTP status
(res.ok), not an ok field in the body. On a 2xx it returns
data. Otherwise it throws an error with the HTTP status, plus
code, message and details copied from the body's
error object. If the body has no error.message, it uses the HTTP status
text. If the body is not JSON, it treats it as empty. So always branch on the HTTP status first.
The body is the input object itself. The SDK sends
JSON.stringify(input) as the body of /estimate, /run and
/run-stream, with no wrapper around it. The page checks the input with
Clkit.mustBeObject, which throws run input must be a plain object for
anything that is not a non-array object.
Errors
Only the cases the SDK or the page actually handle are listed here. For any other status, log
error.code and error.message.
| What you see | What the code does with it | What to do |
|---|---|---|
HTTP 401 | The page treats it as an expired session. It clears its view of the user and says "Your session expired - sign in again." | Get a fresh token (step 2). |
HTTP 402 | The page says "Not enough credits for this run." and links to billing. | Top up the signed-in account, then run again. |
Any other non-2xx, for example a validation error or 429 | The SDK throws with the status and error.code / error.message. The page shows the message. | Read error.code and error.message. Fix the body, or wait and retry with the same Idempotency-Key. |
An SSE error event on /run-stream | The SDK throws with the event's message (or "job failed"), code and job_id. | Report it. The job id tells you which job to look up. |
A job with status: "failed" | GET /jobs/{id} stops polling once the status is succeeded or failed. The job's error field explains the failure. | Read error and decide whether to retry. |
The task field: two lanes
task picks the lane. The app has one system prompt that serves both lanes, and
task decides which one runs. The page only ever sends one of these two values:
task | What it does | What facts is built from |
|---|---|---|
review | Judges a CHANGELOG.md against the browser's findings. Returns a verdict that is never looser than the page's verdict_floor, one finding per error or warn flag, rewrites of entries for users, and what the file is missing. | The parsed changelog: Clkit.reviewFacts(P). |
draft | Writes the next release's entries from the commits since the last release. Every user-facing commit is either cited in an entry or listed in omitted, and every breaking change gets a migration note. | The parsed changelog, the commits and the version plan: Clkit.draftFacts(P, C, plan, {date, merge_unreleased}). |
If task is missing or not recognised, SKILL.md tells the model to answer
as draft when facts has a commits array and as
review otherwise. It then sets lane in the reply to the lane it chose.
Do not rely on this. Always send task.
Input fields
These are the fields declared to the platform in input-schema.json, in order:
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | yes | "review" or "draft". |
facts | string | yes | A JSON string, not a nested object. The page builds it with JSON.stringify(facts). Its keys depend on the lane (see below). |
question | string | no | Your question for the model. Clkit.buildInput trims it, cuts it to 600 characters, and leaves it out when it is empty. The model answers it in summary. |
retry_note | string | no | Only for a reformat retry. The page adds it when the previous reply could not be parsed as the JSON object. It is added to the body sent on the second attempt and is never part of the hashed input. Do not send it on a first attempt. |
When the page's parse fails, it retries once. The retry_note it sends has this form:
Your previous reply was not the single valid JSON object the instructions require (<parse error>). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).
The retry is one extra run. If the second reply does not parse either, the page shows the raw text.
The facts string
facts comes from the page's browser engine, clkit.js. For a review the
page calls Clkit.reviewFacts(P) on the parsed changelog. For a draft it calls
Clkit.draftFacts(P, C, plan, { date, merge_unreleased }) with the parsed changelog,
the parsed commits and the version plan. Clkit.buildInput(lane, facts, question) then
wraps the result as { task, facts: JSON.stringify(facts), question? }. The model treats
every count, version, date and finding in facts as authoritative. It never computes
them itself. As an API caller you must build the same shape.
review facts, top-level keys (in the order reviewFacts writes them):
| Key | Contents |
|---|---|
title | The file's title text, or null. |
today | Today's date, YYYY-MM-DD. |
latest | The newest released version. |
links | { "prefix": ... }, the way the file writes its version links, or null. |
counts | Counts from the check, e.g. releases, unreleased, entries, errors, warns, infos, commit_like. |
verdict_floor | ready, fix_first or rework: the loosest verdict the page allows. |
style | Whether entries end with a period, plus sample entries. |
flags | Findings, each { id, code, severity, release, line, rule, detail, mechanical } and sometimes entries (up to 30 entry ids). |
releases | The sections sent in full, newest first, each { release, line, date, date_iso, date_status, yanked, from_below, sections, untyped, prose_lines }. Each item of sections is { heading, type, entries: [{ id, text }] }. |
releases_not_sent | Older sections summarised as { release, date, entries }. |
notes | Plain-text notes, for example about what was cut or not sent. |
draft facts, top-level keys (in the order draftFacts writes them):
| Key | Contents |
|---|---|
title | The changelog's title text, or null. |
date | The release date the page will write (defaults to today). |
last_version, last_source | The last released version, and where it came from (changelog or entered). |
next_version, level, release_type | The browser's version plan. |
reasons | Which commits drive the level: [{ level, commits, why }]. |
options | The plan's options, e.g. breaking_minor_pre1, feat_patch_pre1, preid, release_anyway. |
commits | User-facing commits, each { id, hash, subject, type, scope, breaking, breaking_note, section, bump, conventional, prs, notes, body }. |
other_commits | Commits left out as not user-facing, each { id, subject, type, why }. |
unreleased | Entries already under Unreleased that are carried into this release, each { type, text }. The array is empty when merge_unreleased is false. |
style, stats, notes | The file's entry style, commit statistics (or null), and up to 12 notes. |
The reply contract
The reply is one JSON object in the job's output text,
output.output, with no prose and no code fences around it. The page reads
done.output.output from a streamed run (step 6), or output.output from a
finished job (step 5), and parses it. SKILL.md requires every array to be present:
an empty array when there is nothing to say, never omitted and never null.
review
{
"lane": "review",
"verdict": "ready" | "fix_first" | "rework",
"headline": "one sentence the maintainer reads first",
"findings": [ { "flag": "F3", "note": "what is wrong here and what to do about it" } ],
"rewrites": [ { "entry": "E4", "after": "the entry rewritten for users", "type": "Fixed", "why": "what the rewrite changes" } ],
"missing": [ { "release": "2.4.0", "type": "Removed", "note": "what the file should say but does not" } ],
"summary": "what to do before the next release is tagged"
}
verdict can match facts.verdict_floor or be stricter, never looser.
findings has one item for every error or warn flag. A problem
that no flag covers gets flag: "". In rewrites, an entry removed as
noise has after: "" and type: "". In missing,
release is "" when the gap is about the whole file.
draft
{
"lane": "draft",
"version": "the browser's next_version, exactly",
"headline": "one sentence on what this release is",
"sections": {
"Added": [ { "text": "the entry", "commits": ["C2"] } ],
"Changed": [], "Deprecated": [], "Removed": [], "Fixed": [], "Security": []
},
"breaking": [ { "commits": ["C1"], "migration": "exactly what a user changes to upgrade" } ],
"omitted": [ { "commits": ["C5"], "why": "why it is not in the changelog" } ],
"version_note": "why this version, in terms of the commits",
"questions": ["what the maintainer must confirm or supply"],
"summary": "..."
}
version is facts.next_version, character for character. If the model
disagrees with it, it explains why in version_note. Every entry cites at least one
commit id. An entry for a breaking change starts with BREAKING:. The page checks
everything the model writes against its own reading of your input. A direct API caller gets only
the raw reply, so run your own checks.
Worked examples: one body per lane
These are the exact inputs the page builds for two of its bundled examples. Save the one you want
as body.json. The samples in steps 4 to 6 send that file unchanged.
review: the Tidequern changelog (shortened)
This body is shortened. The real facts string is 5,523 characters.
Every [... ...] marks a cut, so this text is not valid JSON as shown. The field names
and the values that are shown are exact. It has 14 flags and 4 releases; one of each is shown.
{
"task": "review",
"facts": "{\"title\":\"Changelog\",\"today\":\"2026-09-28\",\"latest\":\"2.4.0\",\"links\":{\"prefix\":\"v\"},\"counts\":{\"releases\":4,\"unreleased\":0,\"entries\":9,\"errors\":0,\"warns\":11,\"infos\":3,\"commit_like\":3},\"verdict_floor\":\"fix_first\",\"style\":{\"entries\":9,\"period\":true,\"samples\":[\"Station search now matches NOAA station ids as well as names.\",[... 5 more samples ...]]},\"flags\":[{\"id\":\"F1\",\"code\":\"unreleased_missing\",\"severity\":\"warn\",\"release\":null,\"line\":5,\"rule\":\"No Unreleased section\",\"detail\":\"Add ## [Unreleased] above 2.4.0.\",\"mechanical\":true},[... flags F2 to F14 ...]],\"releases\":[{\"release\":\"2.4.0\",\"line\":5,\"date\":\"03/14/2026\",\"date_iso\":\"2026-03-14\",\"date_status\":\"format\",\"yanked\":false,\"from_below\":{\"version\":\"2.3.1\",\"level\":\"minor\"},\"sections\":[{\"heading\":\"Features\",\"type\":null,\"entries\":[{\"id\":\"E1\",\"text\":\"feat(tides): add harmonic constituent export (a41c9e2)\"},{\"id\":\"E2\",\"text\":\"Station search now matches NOAA station ids as well as names.\"}]},[... more sections ...]],\"untyped\":[],\"prose_lines\":0},[... releases 2.3.1, 2.1.0, 2.0.0 ...]],\"releases_not_sent\":[],\"notes\":[]}",
"question": "Can we tag 2.5.0 on top of this file, or does it need cleaning first?"
}
draft: the Lumvane 0.9.0 release (complete)
This body is complete and you can send it as it is.
{
"task": "draft",
"facts": "{\"title\":\"Changelog\",\"date\":\"2026-09-28\",\"last_version\":\"0.8.3\",\"last_source\":\"changelog\",\"next_version\":\"0.9.0\",\"level\":\"minor\",\"release_type\":\"minor\",\"reasons\":[{\"level\":\"minor\",\"commits\":[\"C1\",\"C2\"],\"why\":\"feat, and breaking changes counted as minor before 1.0.0\"},{\"level\":\"patch\",\"commits\":[\"C3\"],\"why\":\"fix\"}],\"options\":{\"breaking_minor_pre1\":true,\"feat_patch_pre1\":false,\"preid\":\"\",\"release_anyway\":false},\"commits\":[{\"id\":\"C1\",\"hash\":\"a91f3c2\",\"subject\":\"feat!: rename `Lumvane.render()` to `Lumvane.paint()`\",\"type\":\"feat\",\"scope\":null,\"breaking\":true,\"breaking_note\":\"rename `Lumvane.render()` to `Lumvane.paint()`\",\"section\":\"Changed\",\"bump\":\"minor\",\"conventional\":true,\"prs\":[],\"notes\":[],\"body\":\"\"},{\"id\":\"C2\",\"hash\":\"4be07d1\",\"subject\":\"feat(shader): add bloom pass\",\"type\":\"feat\",\"scope\":\"shader\",\"breaking\":false,\"breaking_note\":\"\",\"section\":\"Added\",\"bump\":\"minor\",\"conventional\":true,\"prs\":[],\"notes\":[],\"body\":\"\"},{\"id\":\"C3\",\"hash\":\"77c1e90\",\"subject\":\"fix: clamp exposure to [0, 16]\",\"type\":\"fix\",\"scope\":null,\"breaking\":false,\"breaking_note\":\"\",\"section\":\"Fixed\",\"bump\":\"patch\",\"conventional\":true,\"prs\":[],\"notes\":[],\"body\":\"\"},{\"id\":\"C4\",\"hash\":\"0d4f2a8\",\"subject\":\"perf: reuse framebuffers between frames\",\"type\":\"perf\",\"scope\":null,\"breaking\":false,\"breaking_note\":\"\",\"section\":\"Changed\",\"bump\":\"none\",\"conventional\":true,\"prs\":[],\"notes\":[],\"body\":\"\"}],\"other_commits\":[{\"id\":\"C5\",\"subject\":\"refactor: split pipeline.ts\",\"type\":\"refactor\",\"why\":\"internal refactor\"},{\"id\":\"C6\",\"subject\":\"test: snapshot the bloom pass\",\"type\":\"test\",\"why\":\"tests only\"}],\"unreleased\":[],\"style\":{\"entries\":2,\"period\":false,\"samples\":[\"sRGB output uses the correct gamma curve\",\"HDR textures load on Safari\"]},\"stats\":{\"total\":6,\"conventional\":6,\"unclassified\":0,\"merges\":0,\"breaking\":1,\"user_facing\":4},\"notes\":[\"The log reaches v0.8.3 at commit c2d1e7f; that commit and everything below it are already released and were left out.\"]}"
}
Idempotency
/run and /run-stream accept an Idempotency-Key request
header. The SDK sends it only when the caller passes a key. The page passes one on every run
attempt, in this format:
changelog-desk:<lane>:<hash>:a<attempt>
Lumvane draft example, serialised the way JSON.stringify does it (no whitespace):
changelog-desk:draft:94e33e8b-8da:a1 (first attempt)
changelog-desk:draft:94e33e8b-8da:a2 (the one reformat retry)
<hash> is computed over JSON.stringify(input), where the input is
the one buildInput produced, before any retry_note is added. It
is a djb2-style 32-bit hash (h = 5381; for each UTF-16 code unit,
h = h*33 + c, modulo 232), written as lowercase hex, then -,
then the string length in hex. <attempt> is 1 for a run and
2 for the reformat retry. The code comments describe the goal: a network blip should
never double-bill, and a reformat retry should be distinguishable from a deliberate re-run. They
also say an idempotent replay of /run-stream may come back as a plain JSON response
instead of an event stream (step 6 handles both).
The samples below compute the same formula over the exact bytes they send. Your key only has to stay stable for one logical attempt, so any stable hash of your body works for your own calls.
1. A tiny client
This helper sends a JSON body that is already serialised. It sets
Content-Type: application/json, adds Authorization: Bearer ... only when
a token is set, returns data on a 2xx, and raises an error with the status,
error.code and error.message otherwise. It takes the body as a string so
that a run's Idempotency-Key is computed over the exact bytes it sends. The later steps assume the
helper is in scope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # from https://changelog-desk.skillsafe.ai/tokens.html
# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero (--fail-with-body) on a non-2xx status.
api() {
local method="$1" path="$2" body="${3:-}"
shift 2; [ $# -gt 0 ] && shift
local args=(-sS --fail-with-body -X "$method" "$BASE$path" -H "Content-Type: application/json")
if [ -n "$TOKEN" ]; then args+=(-H "Authorization: Bearer $TOKEN"); fi
if [ -n "$body" ]; then args+=(--data-binary "@$body"); fi
curl "${args[@]}" "$@"
}
# idem_key LANE BODY_FILE ATTEMPT -> changelog-desk:<lane>:<hash>:a<attempt>
# Hashes bytes, so it equals the page's UTF-16 hash only for an ASCII body.
idem_key() {
local h=5381 n=0 b
for b in $(od -An -v -tu1 "$2"); do h=$(( (h * 33 + b) & 0xFFFFFFFF )); n=$((n + 1)); done
printf 'changelog-desk:%s:%x-%x:a%s\n' "$1" "$h" "$n" "$3"
}
import json, os, time, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://changelog-desk.skillsafe.ai/tokens.html
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def open_request(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else body.encode("utf-8"))
req.add_header("Content-Type", "application/json")
if TOKEN:
req.add_header("Authorization", "Bearer " + TOKEN)
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
return urllib.request.urlopen(req, timeout=300)
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
raise ApiError(e.code, err.get("code"), err.get("message") or e.reason, err.get("details")) from None
def call(method, path, body=None, headers=None):
"""body is an already-serialised JSON string (or None). Returns `data`."""
with open_request(method, path, body, headers) as r:
return json.load(r).get("data")
def idem_key(lane, body, attempt):
units = body.encode("utf-16-le")
h = 5381
for i in range(0, len(units), 2):
h = (h * 33 + int.from_bytes(units[i:i + 2], "little")) & 0xFFFFFFFF
return f"changelog-desk:{lane}:{h:x}-{len(units) // 2:x}:a{attempt}"
// Node 18+ as an ES module (.mjs, for top-level await).
const BASE = "https://api.skillsafe.ai/v1/app-api";
let token = "YOUR_TOKEN"; // paste from https://changelog-desk.skillsafe.ai/tokens.html
function apiError(res, json) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
return err;
}
function send(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (token) h.Authorization = "Bearer " + token;
return fetch(BASE + path, { method, headers: h, body });
}
// body is an already-serialised JSON string (or undefined). Returns `data`.
async function call(method, path, body, headers) {
const res = await send(method, path, body, headers);
const json = await res.json().catch(() => ({}));
if (!res.ok) throw apiError(res, json);
return json.data;
}
// The page's own formula (app.js inputHash), over the exact string you send.
function idemKey(lane, body, attempt) {
let h = 5381;
for (let i = 0; i < body.length; i++) h = ((h << 5) + h + body.charCodeAt(i)) | 0;
return `changelog-desk:${lane}:${(h >>> 0).toString(16)}-${body.length.toString(16)}:a${attempt}`;
}
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"unicode/utf16"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// From https://changelog-desk.skillsafe.ai/tokens.html
var token = os.Getenv("SKILLSAFE_TOKEN")
type APIError struct {
Status int
Code, Message string
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
func send(method, path string, body []byte, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequest(method, base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
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)
}
return http.DefaultClient.Do(req)
}
// decode unwraps `data`, or returns an *APIError on a non-2xx status.
func decode(res *http.Response) (map[string]any, error) {
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var env struct {
Data map[string]any `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(raw, &env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := &APIError{Status: res.StatusCode, Message: res.Status}
if env.Error != nil {
e.Code = env.Error.Code
if env.Error.Message != "" {
e.Message = env.Error.Message
}
}
return nil, e
}
return env.Data, nil
}
func call(method, path string, body []byte, headers map[string]string) (map[string]any, error) {
res, err := send(method, path, body, headers)
if err != nil {
return nil, err
}
return decode(res)
}
func idemKey(lane string, body []byte, attempt int) string {
units := utf16.Encode([]rune(string(body)))
h := uint32(5381)
for _, c := range units {
h = h*33 + uint32(c)
}
return fmt.Sprintf("changelog-desk:%s:%x-%x:a%d", lane, h, len(units), attempt)
}
// Keep the compiler quiet until steps 5 and 6 use these.
var _, _, _ = bufio.NewReader, strings.HasPrefix, time.Sleep
// Java 11+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.*;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Map;
public class ChangelogDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// From https://changelog-desk.skillsafe.ai/tokens.html
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends RuntimeException {
final int status; final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status; this.code = code;
}
}
static HttpRequest request(String method, String path, String body, Map<String, String> headers) {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
headers.forEach(b::header);
return b.build();
}
static JsonNode parse(String raw) {
try { JsonNode n = JSON.readTree(raw); return n == null ? JSON.createObjectNode() : n; }
catch (IOException e) { return JSON.createObjectNode(); }
}
static ApiException error(int status, JsonNode json) {
JsonNode err = json.path("error");
return new ApiException(status, err.path("code").asText(null), err.path("message").asText("HTTP " + status));
}
/** Returns the `data` node. */
static JsonNode call(String method, String path, String body, Map<String, String> headers) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, headers), HttpResponse.BodyHandlers.ofString());
JsonNode json = parse(res.body());
if (res.statusCode() / 100 != 2) throw error(res.statusCode(), json);
return json.path("data");
}
static String idemKey(String lane, String body, int attempt) {
int h = 5381;
for (int i = 0; i < body.length(); i++) h = (h << 5) + h + body.charAt(i);
return "changelog-desk:" + lane + ":" + Integer.toHexString(h) + "-"
+ Integer.toHexString(body.length()) + ":a" + attempt;
}
// The methods in steps 2 to 6 go inside this class.
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://changelog-desk.skillsafe.ai/tokens.html
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status, @code = status, code
end
end
def build_request(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" unless $token.to_s.empty?
headers.each { |k, v| req[k] = v }
req.body = body if body
[uri, req]
end
def raise_for(res)
return if res.is_a?(Net::HTTPSuccess)
json = (JSON.parse(res.body.to_s) rescue {})
err = (json.is_a?(Hash) && json["error"]) || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
# body is an already-serialised JSON string (or nil). Returns `data`.
def call(method, path, body = nil, headers = {})
uri, req = build_request(method, path, body, headers)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |http| http.request(req) }
raise_for(res)
JSON.parse(res.body)["data"]
end
def idem_key(lane, body, attempt)
units = body.encode("UTF-16LE").unpack("v*")
h = units.reduce(5381) { |acc, c| (acc * 33 + c) & 0xFFFFFFFF }
format("changelog-desk:%s:%x-%x:a%d", lane, h, units.length, attempt)
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // from https://changelog-desk.skillsafe.ai/tokens.html
class ApiError extends Exception {
public $status; public $apiCode;
function __construct($status, $code, $message) {
parent::__construct("$status $code: $message");
$this->status = $status; $this->apiCode = $code;
}
}
function header_lines(array $extra = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($extra as $k => $v) $h[] = "$k: $v";
return $h;
}
function api_error($status, $raw) {
$json = json_decode((string)$raw, true);
$err = is_array($json) ? ($json["error"] ?? []) : [];
return new ApiError($status, $err["code"] ?? null, $err["message"] ?? "HTTP $status");
}
// $body is an already-serialised JSON string (or null). Returns `data`.
function call($method, $path, $body = null, array $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => header_lines($headers)]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status < 200 || $status > 299) throw api_error($status, $raw);
return json_decode($raw, true)["data"] ?? null;
}
function idem_key($lane, $body, $attempt) {
$units = array_values(unpack("v*", mb_convert_encoding($body, "UTF-16LE", "UTF-8")) ?: []);
$h = 5381;
foreach ($units as $c) $h = ($h * 33 + $c) & 0xFFFFFFFF;
return sprintf("changelog-desk:%s:%x-%x:a%d", $lane, $h, count($units), $attempt);
}
// .NET 6+ top-level program.
using System.Net;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
// From https://changelog-desk.skillsafe.ai/tokens.html
string token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
HttpRequestMessage Request(HttpMethod method, string path, string? body, Dictionary<string, string>? headers)
{
var req = new HttpRequestMessage(method, Base + path);
// HttpClient carries Content-Type on the content, so a GET goes without it.
if (body != null) req.Content = new StringContent(body, Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(token)) req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
foreach (var kv in headers ?? new()) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
return req;
}
HttpRequestException ApiError(HttpStatusCode status, string raw)
{
string? code = null, msg = status.ToString();
try
{
var root = JsonDocument.Parse(raw).RootElement;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("error", out var err)
&& err.ValueKind == JsonValueKind.Object)
{
if (err.TryGetProperty("code", out var c)) code = c.GetString();
if (err.TryGetProperty("message", out var m)) msg = m.GetString();
}
}
catch (JsonException) { }
return new HttpRequestException($"{(int)status} {code}: {msg}", null, status);
}
// Returns the `data` element.
async Task<JsonElement> Call(HttpMethod method, string path, string? body = null, Dictionary<string, string>? headers = null)
{
using var res = await http.SendAsync(Request(method, path, body, headers));
var raw = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) throw ApiError(res.StatusCode, raw);
return JsonDocument.Parse(raw).RootElement.GetProperty("data").Clone();
}
string IdemKey(string lane, string body, int attempt)
{
uint h = 5381;
foreach (char c in body) h = unchecked(h * 33 + c);
return $"changelog-desk:{lane}:{h:x}-{body.Length:x}:a{attempt}";
}
2. Get a token
There are two kinds of token. As the tokens page puts it, a
guest token "is minted automatically and is enough to call
/me and /estimate". Reviewing a changelog or drafting a release is
metered, so it needs a personal token, which you get by signing in.
- Personal token (to run). Open /tokens.html, press
Sign in with SkillSafe, then press Copy token. The token is the one this
browser holds for
changelog-desk. Removing it on that page only deletes it from the browser, not on the server. A copy you pasted somewhere else keeps working until it expires, so treat a token you have shared as compromised. - Guest token (to look). The SDK gets one with
POST /guestand the body{"slug": "changelog-desk"}, sent with no token. It then readsdata.token(and keepsdata.guest_id).
# Guest token (needs jq). No Authorization header on this call.
TOKEN=$(curl -sS --fail-with-body -X POST "$BASE/guest" \
-H "Content-Type: application/json" --data '{"slug":"changelog-desk"}' | jq -r '.data.token')
# Or a personal token copied from https://changelog-desk.skillsafe.ai/tokens.html
TOKEN="YOUR_TOKEN"
def guest_token():
global TOKEN
TOKEN = "" # the guest call carries no token
TOKEN = call("POST", "/guest", json.dumps({"slug": "changelog-desk"}))["token"]
return TOKEN
# Or: TOKEN = "YOUR_TOKEN" (personal, from /tokens.html)
async function guestToken() {
token = ""; // the guest call carries no token
const data = await call("POST", "/guest", JSON.stringify({ slug: "changelog-desk" }));
token = data.token;
return data; // { token, guest_id, ... }
}
// Or: token = "YOUR_TOKEN"; (personal, from /tokens.html)
func guestToken() error {
token = "" // the guest call carries no token
data, err := call("POST", "/guest", []byte(`{"slug":"changelog-desk"}`), nil)
if err != nil {
return err
}
token, _ = data["token"].(string)
return nil
}
// Or: SKILLSAFE_TOKEN=YOUR_TOKEN (personal, from /tokens.html)
static void guestToken() throws Exception {
token = ""; // the guest call carries no token
token = call("POST", "/guest", "{\"slug\":\"changelog-desk\"}", Map.of()).path("token").asText();
}
// Or: SKILLSAFE_TOKEN=YOUR_TOKEN (personal, from /tokens.html)
def guest_token
$token = "" # the guest call carries no token
$token = call("POST", "/guest", JSON.generate(slug: "changelog-desk"))["token"]
end
# Or: $token = "YOUR_TOKEN" (personal, from /tokens.html)
function guest_token() {
global $TOKEN;
$TOKEN = ""; // the guest call carries no token
$TOKEN = call("POST", "/guest", json_encode(["slug" => "changelog-desk"]))["token"];
return $TOKEN;
}
// Or: $TOKEN = "YOUR_TOKEN"; (personal, from /tokens.html)
async Task GuestToken()
{
token = ""; // the guest call carries no token
var data = await Call(HttpMethod.Post, "/guest", "{\"slug\":\"changelog-desk\"}");
token = data.GetProperty("token").GetString()!;
}
// Or: SKILLSAFE_TOKEN=YOUR_TOKEN (personal, from /tokens.html)
3. Check the session and the balance
GET /me returns the caller: subject_type, subject_id,
credits and sometimes profile. The page counts the caller as signed in
only when subject_type is "user", and shows credits as the
balance. Run this before a run to make sure you are holding a personal token.
api GET /me
# {"data":{"subject_type":"user","subject_id":"...","credits":...}}
me = call("GET", "/me")
signed_in = me.get("subject_type") == "user"
print(me.get("subject_type"), me.get("credits"), "signed in" if signed_in else "guest")
const me = await call("GET", "/me");
const signedIn = me.subject_type === "user";
console.log(me.subject_type, me.credits, signedIn ? "signed in" : "guest");
me, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
signedIn := me["subject_type"] == "user"
fmt.Println(me["subject_type"], me["credits"], signedIn)
JsonNode me = call("GET", "/me", null, Map.of());
boolean signedIn = "user".equals(me.path("subject_type").asText());
System.out.println(me.path("subject_type").asText() + " " + me.path("credits") + " " + signedIn);
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
puts "#{me["subject_type"]} #{me["credits"]} #{signed_in ? "signed in" : "guest"}"
$me = call("GET", "/me");
$signedIn = ($me["subject_type"] ?? null) === "user";
echo ($me["subject_type"] ?? "?") . " " . ($me["credits"] ?? "?") . " " . ($signedIn ? "signed in" : "guest") . "\n";
var me = await Call(HttpMethod.Get, "/me");
var signedIn = me.GetProperty("subject_type").GetString() == "user";
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")} {(signedIn ? "signed in" : "guest")}");
4. Price it
POST /estimate takes the same body you would send to a run. The SDK describes it as
the "worst-case credit cost of a run with this input (no charge, no job)". The page
reads these fields from the response:
| Field | How the page uses it |
|---|---|
hold_credits | Shown as the number of credits the run "reserves" for the lane. If your balance is below it, the page warns that the run may be cut short. |
min_credits | Shown as the minimum. The page disables its run button when your balance cannot cover it. |
sponsor_enabled | The page lets a guest start a run only when this is true. Otherwise it asks the user to sign in. |
The hold is a reservation. The charge that is actually settled is reported after the run as
charged_credits (steps 5 and 6). The page converts credits to dollars at 10,000
credits per dollar. Each lane is priced on its own input.
If the response carries a warnings array, the page shows it next to the price as "input warnings". What we observed on this app on 2026-09-28, with a guest token: each of the page's five example bodies returned an estimate with an empty warnings array; an empty body {} still returned a hold, with warnings naming the missing required fields task and facts; a body wrapped as {"input": {...}} also returned a hold, with a further warning naming the unknown field input and listing the declared fields; and a bare JSON string was rejected with HTTP 400 validation_error. A warning is not a rejection, so check warnings yourself before you run.
# body.json is one of the worked examples ("task": "review" or "draft").
api POST /estimate body.json | jq '.data | {hold_credits, min_credits, sponsor_enabled}'
with open("body.json", encoding="utf-8") as f:
body = f.read()
est = call("POST", "/estimate", body)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"), "sponsor", est.get("sponsor_enabled"))
import { readFile } from "node:fs/promises";
const body = await readFile("body.json", "utf8");
const est = await call("POST", "/estimate", body);
console.log("hold", est.hold_credits, "min", est.min_credits, "sponsor", est.sponsor_enabled);
body, err := os.ReadFile("body.json")
if err != nil {
panic(err)
}
est, err := call("POST", "/estimate", body, nil)
if err != nil {
panic(err)
}
fmt.Println("hold", est["hold_credits"], "min", est["min_credits"], "sponsor", est["sponsor_enabled"])
String body = Files.readString(Path.of("body.json"), StandardCharsets.UTF_8);
JsonNode est = call("POST", "/estimate", body, Map.of());
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsor " + est.path("sponsor_enabled"));
body = File.read("body.json", encoding: "UTF-8")
est = call("POST", "/estimate", body)
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} sponsor #{est["sponsor_enabled"]}"
$body = file_get_contents("body.json");
$est = call("POST", "/estimate", $body);
printf("hold %s min %s sponsor %s\n", $est["hold_credits"] ?? "?", $est["min_credits"] ?? "?",
var_export($est["sponsor_enabled"] ?? null, true));
var body = await File.ReadAllTextAsync("body.json");
var est = await Call(HttpMethod.Post, "/estimate", body);
Console.WriteLine($"hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")} sponsor {est.GetProperty("sponsor_enabled")}");
5. Run it, then poll the job
/run and /run-stream are metered. They spend the
signed-in user's credits, so send a personal token (step 2). The page calls
these lanes metered. It sends a guest to sign in before it runs anything, unless
/estimate reported sponsor_enabled: true.
POST /run with the body and an Idempotency-Key header returns
data.job_id. Then poll GET /jobs/{id} until status is
succeeded or failed. The SDK polls every 1 second and gives up after
180 seconds. A finished job has these fields:
| Field | Meaning |
|---|---|
status | succeeded or failed once the job is finished. |
output.output | The reply text: the lane's JSON object (see the reply contract). |
charged_credits | The charge that was settled for this job. |
truncated | true when the reply hit the run's output cap. The SDK describes this for budget-scaled runs, and the page reports it as "cut short by the available balance". Parse what arrived and top up before you run again. |
error | Why a failed job failed. |
If the reply does not parse, do what the page does. Send the same body once more
with a retry_note field (the text is in Input fields) and the key
ending in :a2. Compute the hash over the body without the note. This is a
second run and it is charged.
LANE=$(jq -r '.task' body.json) # "review" or "draft"
KEY=$(idem_key "$LANE" body.json 1) # changelog-desk:<lane>:<hash>:a1
JOB=$(api POST /run body.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')
for i in $(seq 1 180); do
api GET "/jobs/$JOB" > job.json
STATUS=$(jq -r '.data.status' job.json)
if [ "$STATUS" = succeeded ] || [ "$STATUS" = failed ]; then break; fi
sleep 1
done
jq '.data | {status, charged_credits, truncated, error}' job.json
jq -r '.data.output.output' job.json | jq . # the lane's JSON object
with open("body.json", encoding="utf-8") as f:
body = f.read()
lane = json.loads(body)["task"] # "review" or "draft"
key = idem_key(lane, body, 1)
job_id = call("POST", "/run", body, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180
while True:
job = call("GET", f"/jobs/{job_id}")
if job.get("status") in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise RuntimeError(f"job failed: {job.get('error')}")
reply = json.loads(job["output"]["output"]) # the lane's JSON object
print(reply["lane"], reply["headline"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated") is True)
import { readFile } from "node:fs/promises";
const body = await readFile("body.json", "utf8");
const lane = JSON.parse(body).task; // "review" or "draft"
const key = idemKey(lane, body, 1);
const { job_id } = await call("POST", "/run", body, { "Idempotency-Key": key });
const start = Date.now();
let job;
for (;;) {
job = await call("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() - start > 180000) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("job failed: " + JSON.stringify(job.error));
const reply = JSON.parse(job.output.output); // the lane's JSON object
console.log(reply.lane, reply.headline);
console.log("charged", job.charged_credits, "truncated", job.truncated === true);
body, err := os.ReadFile("body.json")
if err != nil {
panic(err)
}
var in struct {
Task string `json:"task"` // "review" or "draft"
}
if err := json.Unmarshal(body, &in); err != nil {
panic(err)
}
key := idemKey(in.Task, body, 1)
started, err := call("POST", "/run", body, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
jobID, _ := started["job_id"].(string)
var job map[string]any
deadline := time.Now().Add(180 * time.Second)
for {
job, err = call("GET", "/jobs/"+jobID, nil, nil)
if err != nil {
panic(err)
}
if s := job["status"]; s == "succeeded" || s == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job["status"] == "failed" {
panic(fmt.Sprint("job failed: ", job["error"]))
}
out, _ := job["output"].(map[string]any)
text, _ := out["output"].(string)
var reply map[string]any
if err := json.Unmarshal([]byte(text), &reply); err != nil {
panic(err) // not the JSON object: retry once with retry_note (see above)
}
fmt.Println(reply["lane"], reply["headline"])
fmt.Println("charged", job["charged_credits"], "truncated", job["truncated"] == true)
static JsonNode runAndWait() throws Exception {
String body = Files.readString(Path.of("body.json"), StandardCharsets.UTF_8);
String lane = JSON.readTree(body).path("task").asText(); // "review" or "draft"
String key = idemKey(lane, body, 1);
String jobId = call("POST", "/run", body, Map.of("Idempotency-Key", key)).path("job_id").asText();
long deadline = System.currentTimeMillis() + 180_000;
JsonNode job;
for (;;) {
job = call("GET", "/jobs/" + jobId, null, Map.of());
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new RuntimeException("job failed: " + job.path("error"));
JsonNode reply = JSON.readTree(job.path("output").path("output").asText()); // the lane's JSON object
System.out.println(reply.path("lane").asText() + ": " + reply.path("headline").asText());
System.out.println("charged " + job.path("charged_credits") + " truncated " + job.path("truncated").asBoolean(false));
return reply;
}
body = File.read("body.json", encoding: "UTF-8")
lane = JSON.parse(body)["task"] # "review" or "draft"
key = idem_key(lane, body, 1)
job_id = call("POST", "/run", body, "Idempotency-Key" => key)["job_id"]
deadline = Time.now + 180
job = nil
loop do
job = call("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "job failed: #{job["error"].inspect}" if job["status"] == "failed"
reply = JSON.parse(job.dig("output", "output")) # the lane's JSON object
puts "#{reply["lane"]}: #{reply["headline"]}"
puts "charged #{job["charged_credits"]} truncated #{job["truncated"] == true}"
$body = file_get_contents("body.json");
$lane = json_decode($body, true)["task"]; // "review" or "draft"
$key = idem_key($lane, $body, 1);
$jobId = call("POST", "/run", $body, ["Idempotency-Key" => $key])["job_id"];
$deadline = time() + 180;
do {
$job = call("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job["status"] ?? "", ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
} while (true);
if ($job["status"] === "failed") throw new RuntimeException("job failed: " . json_encode($job["error"] ?? null));
$reply = json_decode($job["output"]["output"], true); // the lane's JSON object
echo $reply["lane"] . ": " . $reply["headline"] . "\n";
echo "charged " . ($job["charged_credits"] ?? "?") . " truncated " . var_export(($job["truncated"] ?? false) === true, true) . "\n";
var body = await File.ReadAllTextAsync("body.json");
var lane = JsonDocument.Parse(body).RootElement.GetProperty("task").GetString()!; // "review" or "draft"
var key = IdemKey(lane, body, 1);
var started = await Call(HttpMethod.Post, "/run", body, new() { ["Idempotency-Key"] = key });
var jobId = started.GetProperty("job_id").GetString()!;
var deadline = DateTime.UtcNow.AddSeconds(180);
JsonElement job;
while (true)
{
job = await Call(HttpMethod.Get, "/jobs/" + Uri.EscapeDataString(jobId));
var s = job.GetProperty("status").GetString();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job.GetProperty("status").GetString() == "failed") throw new Exception("job failed: " + job.GetProperty("error"));
var reply = JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine($"{reply.GetProperty("lane")}: {reply.GetProperty("headline")}");
var truncated = job.TryGetProperty("truncated", out var t) && t.ValueKind == JsonValueKind.True;
Console.WriteLine($"charged {job.GetProperty("charged_credits")} truncated {truncated}");
6. Or stream it
POST /run-stream takes the same body and Idempotency-Key header. It is
metered like /run and needs a personal token. When the response's
Content-Type is text/event-stream, it is a server-sent event stream.
Events are separated by a blank line. Each event has an event: line and one or more
data: lines, and the SDK joins the data: lines into one JSON value. This
is how the SDK handles each event:
| Event | Data | What the SDK does |
|---|---|---|
job | The job that was started. | Passes it to onJob. The page uses it to move its progress display forward. |
delta | { "text": "..." } | Passes text to onDelta. |
done | { job_id, status, charged_credits, output, ... } | Keeps it as the result. The reply text is output.output. |
error | { message, code, job_id } | Once the stream ends, throws with message (or "job failed"), code and job_id. |
The SDK also treats a pending event as a result, the same way as done.
It skips events whose data is empty or is not valid JSON.
Read the reply from done. The page takes
done.output.output and falls back to the joined delta texts only when that
is missing. Its code notes that browsers receive ticks, not deltas, from this endpoint.
done.truncated means the reply was cut short, the same as on a job.
A replay may not be a stream. According to the SDK, on an idempotent replay
/run-stream can answer with a plain JSON envelope instead of an event stream. So
check the Content-Type first. If it is not text/event-stream, read the
response as { data } or { error }, exactly as in step 1.
LANE=$(jq -r '.task' body.json) # "review" or "draft"
KEY=$(idem_key "$LANE" body.json 1)
# -N turns off buffering so events print as they arrive.
api POST /run-stream body.json -N -H "Idempotency-Key: $KEY" | tee stream.txt
# Pull the reply out of the done event (a replay prints plain JSON instead: use .data).
awk '/^event:/ { ev = $2 } /^data:/ { if (ev == "done") { sub(/^data: ?/, ""); print } }' stream.txt \
| jq -r '.output.output' | jq .
with open("body.json", encoding="utf-8") as f:
body = f.read()
key = idem_key(json.loads(body)["task"], body, 1)
text, done, failure = "", None, None
def handle(event, data):
global text, done, failure
if not data:
return
try:
payload = json.loads(data)
except ValueError:
return
if event == "delta":
text += payload.get("text") or ""
elif event == "job":
print("job", payload)
elif event in ("done", "pending"):
done = payload
elif event == "error":
failure = payload
with open_request("POST", "/run-stream", body, {"Idempotency-Key": key}) as r:
if "text/event-stream" not in (r.headers.get("Content-Type") or ""):
done = json.load(r).get("data") # idempotent replay: plain JSON
else:
event, data = "message", ""
for raw in r:
line = raw.decode("utf-8").rstrip("\r\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
elif line == "":
handle(event, data)
event, data = "message", ""
if failure:
raise RuntimeError(f"{failure.get('code')}: {failure.get('message') or 'job failed'} (job {failure.get('job_id')})")
full = ((done or {}).get("output") or {}).get("output") or text
reply = json.loads(full) # the lane's JSON object
print(reply["lane"], reply["headline"])
print("charged", (done or {}).get("charged_credits"), "truncated", (done or {}).get("truncated") is True)
import { readFile } from "node:fs/promises";
const body = await readFile("body.json", "utf8");
const key = idemKey(JSON.parse(body).task, body, 1);
const res = await send("POST", "/run-stream", body, { "Idempotency-Key": key });
let done = null, failure = null, text = "";
if ((res.headers.get("content-type") || "").indexOf("text/event-stream") === -1) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw apiError(res, json);
done = json.data; // idempotent replay: plain JSON
} else {
// The same parser as sdk.js _readSse.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") text += data.text || "";
else if (event === "job") console.log("job", data);
else if (event === "done" || event === "pending") done = data;
else if (event === "error") failure = data;
}
}
}
if (failure) throw Object.assign(new Error(failure.message || "job failed"), { code: failure.code, job_id: failure.job_id });
const reply = JSON.parse((done && done.output && done.output.output) || text); // the lane's JSON object
console.log(reply.lane, reply.headline);
console.log("charged", done && done.charged_credits, "truncated", !!(done && done.truncated));
body, err := os.ReadFile("body.json")
if err != nil {
panic(err)
}
var in struct {
Task string `json:"task"`
}
_ = json.Unmarshal(body, &in)
res, err := send("POST", "/run-stream", body, map[string]string{"Idempotency-Key": idemKey(in.Task, body, 1)})
if err != nil {
panic(err)
}
var done, failure map[string]any
var text strings.Builder
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
if done, err = decode(res); err != nil { // idempotent replay: plain JSON
panic(err)
}
} else {
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
event, data := "message", ""
dispatch := func() {
var payload map[string]any
if data == "" || json.Unmarshal([]byte(data), &payload) != nil {
return
}
switch event {
case "delta":
s, _ := payload["text"].(string)
text.WriteString(s)
case "job":
fmt.Println("job", payload)
case "done", "pending":
done = payload
case "error":
failure = payload
}
}
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "":
dispatch()
event, data = "message", ""
}
}
}
if failure != nil {
panic(fmt.Sprint(failure["code"], ": ", failure["message"], " (job ", failure["job_id"], ")"))
}
full := text.String()
if out, ok := done["output"].(map[string]any); ok {
if s, ok := out["output"].(string); ok && s != "" {
full = s
}
}
var reply map[string]any
if err := json.Unmarshal([]byte(full), &reply); err != nil {
panic(err)
}
fmt.Println(reply["lane"], reply["headline"])
fmt.Println("charged", done["charged_credits"], "truncated", done["truncated"] == true)
static JsonNode stream() throws Exception {
String body = Files.readString(Path.of("body.json"), StandardCharsets.UTF_8);
String key = idemKey(JSON.readTree(body).path("task").asText(), body, 1);
HttpResponse<InputStream> res = HTTP.send(request("POST", "/run-stream", body, Map.of("Idempotency-Key", key)),
HttpResponse.BodyHandlers.ofInputStream());
JsonNode done = null, failure = null;
StringBuilder text = new StringBuilder();
String ctype = res.headers().firstValue("content-type").orElse("");
if (!ctype.contains("text/event-stream")) {
JsonNode json = parse(new String(res.body().readAllBytes(), StandardCharsets.UTF_8));
if (res.statusCode() / 100 != 2) throw error(res.statusCode(), json);
done = json.path("data"); // idempotent replay: plain JSON
} else {
try (BufferedReader in = new BufferedReader(new InputStreamReader(res.body(), StandardCharsets.UTF_8))) {
String event = "message", line;
StringBuilder data = new StringBuilder();
while ((line = in.readLine()) != null) {
if (line.startsWith("event:")) { event = line.substring(6).trim(); continue; }
if (line.startsWith("data:")) { data.append(line.substring(5).trim()); continue; }
if (!line.isEmpty()) continue;
if (data.length() > 0) {
JsonNode payload = null;
try { payload = JSON.readTree(data.toString()); } catch (IOException e) { /* skip */ }
if (payload != null) {
switch (event) {
case "delta": text.append(payload.path("text").asText("")); break;
case "job": System.out.println("job " + payload); break;
case "done": case "pending": done = payload; break;
case "error": failure = payload; break;
default: break;
}
}
}
event = "message"; data.setLength(0);
}
}
}
if (failure != null) throw new RuntimeException(failure.path("code").asText() + ": "
+ failure.path("message").asText("job failed") + " (job " + failure.path("job_id").asText() + ")");
String full = done != null ? done.path("output").path("output").asText("") : "";
JsonNode reply = JSON.readTree(full.isEmpty() ? text.toString() : full); // the lane's JSON object
System.out.println(reply.path("lane").asText() + ": " + reply.path("headline").asText());
System.out.println("charged " + (done == null ? "?" : done.path("charged_credits"))
+ " truncated " + (done != null && done.path("truncated").asBoolean(false)));
return reply;
}
body = File.read("body.json", encoding: "UTF-8")
key = idem_key(JSON.parse(body)["task"], body, 1)
uri, req = build_request("POST", "/run-stream", body, "Idempotency-Key" => key)
text, done, failure = +"", nil, nil
handle = lambda do |event, data|
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then text << payload["text"].to_s
when "job" then puts "job #{payload}"
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["Content-Type"].to_s.include?("text/event-stream")
raw = res.read_body
raise_for(res)
done = JSON.parse(raw)["data"] # idempotent replay: plain JSON
next
end
buffer = +""
res.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
raw = buffer.slice!(0, idx + 2)
event, data = "message", +""
raw.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
handle.call(event, data)
end
end
end
end
raise "#{failure["code"]}: #{failure["message"] || "job failed"} (job #{failure["job_id"]})" if failure
full = done&.dig("output", "output") || text
reply = JSON.parse(full) # the lane's JSON object
puts "#{reply["lane"]}: #{reply["headline"]}"
puts "charged #{done&.fetch("charged_credits", nil)} truncated #{done&.fetch("truncated", nil) == true}"
$body = file_get_contents("body.json");
$key = idem_key(json_decode($body, true)["task"], $body, 1);
$text = ""; $done = null; $failure = null; $buffer = ""; $isStream = null; $plain = "";
$handle = function ($event, $data) use (&$text, &$done, &$failure) {
if ($data === "") return;
$payload = json_decode($data, true);
if (!is_array($payload)) return;
if ($event === "delta") $text .= $payload["text"] ?? "";
elseif ($event === "job") echo "job " . $data . "\n";
elseif ($event === "done" || $event === "pending") $done = $payload;
elseif ($event === "error") $failure = $payload;
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => header_lines(["Idempotency-Key" => $key]),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buffer, &$isStream, &$plain, $handle) {
if ($isStream === null) {
$isStream = strpos((string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE), "text/event-stream") !== false;
}
if (!$isStream) { $plain .= $chunk; return strlen($chunk); }
$buffer .= $chunk;
while (($idx = strpos($buffer, "\n\n")) !== false) {
$raw = substr($buffer, 0, $idx);
$buffer = substr($buffer, $idx + 2);
$event = "message"; $data = "";
foreach (explode("\n", $raw) as $line) {
if (strncmp($line, "event:", 6) === 0) $event = trim(substr($line, 6));
elseif (strncmp($line, "data:", 5) === 0) $data .= trim(substr($line, 5));
}
$handle($event, $data);
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if (!$isStream) {
if ($status < 200 || $status > 299) throw api_error($status, $plain);
$done = json_decode($plain, true)["data"] ?? null; // idempotent replay: plain JSON
}
if ($failure) throw new RuntimeException(($failure["code"] ?? "") . ": " . ($failure["message"] ?? "job failed"));
$reply = json_decode($done["output"]["output"] ?? $text, true); // the lane's JSON object
echo $reply["lane"] . ": " . $reply["headline"] . "\n";
echo "charged " . ($done["charged_credits"] ?? "?") . " truncated " . var_export(($done["truncated"] ?? false) === true, true) . "\n";
var body = await File.ReadAllTextAsync("body.json");
var key = IdemKey(JsonDocument.Parse(body).RootElement.GetProperty("task").GetString()!, body, 1);
using var req = Request(HttpMethod.Post, "/run-stream", body, new() { ["Idempotency-Key"] = key });
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
JsonElement? done = null, failure = null;
var text = new StringBuilder();
var ctype = res.Content.Headers.ContentType?.MediaType ?? "";
if (ctype != "text/event-stream")
{
var raw = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) throw ApiError(res.StatusCode, raw);
done = JsonDocument.Parse(raw).RootElement.GetProperty("data").Clone(); // idempotent replay
}
else
{
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string ev = "message", data = "";
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) { ev = line[6..].Trim(); continue; }
if (line.StartsWith("data:")) { data += line[5..].Trim(); continue; }
if (line.Length != 0) continue;
if (data.Length > 0)
{
JsonElement? payload = null;
try { payload = JsonDocument.Parse(data).RootElement.Clone(); } catch (JsonException) { }
if (payload is JsonElement p)
{
if (ev == "delta" && p.TryGetProperty("text", out var tx)) text.Append(tx.GetString());
else if (ev == "job") Console.WriteLine("job " + p);
else if (ev == "done" || ev == "pending") done = p;
else if (ev == "error") failure = p;
}
}
ev = "message"; data = "";
}
}
if (failure is JsonElement f) throw new Exception($"{f.GetProperty("code")}: {f.GetProperty("message")}");
string full = text.ToString();
if (done is JsonElement d && d.TryGetProperty("output", out var o) && o.ValueKind == JsonValueKind.Object
&& o.TryGetProperty("output", out var s) && s.ValueKind == JsonValueKind.String) full = s.GetString()!;
var reply = JsonDocument.Parse(full).RootElement; // the lane's JSON object
Console.WriteLine($"{reply.GetProperty("lane")}: {reply.GetProperty("headline")}");
What the page does that you now do
- Builds
factsfrom the changelog and the commits (clkit.js). - Sends one run per attempt with a stable Idempotency-Key, and retries once with
retry_notewhen the reply does not parse. - Keeps whatever parsed when a stream ends early, and reports a truncated reply.
- Checks every line of the reply against its own reading of the input before showing it.
Or skip all of this and use Changelog Desk in the browser. The check and the mechanical fixes there are free.