← Changelog Desk / API
Tokens

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 seeWhat the code does with itWhat to do
HTTP 401The 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 402The 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 429The 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-streamThe 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:

taskWhat it doesWhat facts is built from
reviewJudges 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).
draftWrites 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:

FieldTypeRequiredMeaning
taskstringyes"review" or "draft".
factsstringyesA JSON string, not a nested object. The page builds it with JSON.stringify(facts). Its keys depend on the lane (see below).
questionstringnoYour 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_notestringnoOnly 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):

KeyContents
titleThe file's title text, or null.
todayToday's date, YYYY-MM-DD.
latestThe newest released version.
links{ "prefix": ... }, the way the file writes its version links, or null.
countsCounts from the check, e.g. releases, unreleased, entries, errors, warns, infos, commit_like.
verdict_floorready, fix_first or rework: the loosest verdict the page allows.
styleWhether entries end with a period, plus sample entries.
flagsFindings, each { id, code, severity, release, line, rule, detail, mechanical } and sometimes entries (up to 30 entry ids).
releasesThe 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_sentOlder sections summarised as { release, date, entries }.
notesPlain-text notes, for example about what was cut or not sent.

draft facts, top-level keys (in the order draftFacts writes them):

KeyContents
titleThe changelog's title text, or null.
dateThe release date the page will write (defaults to today).
last_version, last_sourceThe last released version, and where it came from (changelog or entered).
next_version, level, release_typeThe browser's version plan.
reasonsWhich commits drive the level: [{ level, commits, why }].
optionsThe plan's options, e.g. breaking_minor_pre1, feat_patch_pre1, preid, release_anyway.
commitsUser-facing commits, each { id, hash, subject, type, scope, breaking, breaking_note, section, bump, conventional, prs, notes, body }.
other_commitsCommits left out as not user-facing, each { id, subject, type, why }.
unreleasedEntries already under Unreleased that are carried into this release, each { type, text }. The array is empty when merge_unreleased is false.
style, stats, notesThe 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"
}

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.

# 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"

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":...}}

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:

FieldHow the page uses it
hold_creditsShown 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_creditsShown as the minimum. The page disables its run button when your balance cannot cover it.
sponsor_enabledThe 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}'

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:

FieldMeaning
statussucceeded or failed once the job is finished.
output.outputThe reply text: the lane's JSON object (see the reply contract).
charged_creditsThe charge that was settled for this job.
truncatedtrue 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.
errorWhy 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

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:

EventDataWhat the SDK does
jobThe 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 .

What the page does that you now do

Or skip all of this and use Changelog Desk in the browser. The check and the mechanical fixes there are free.