KB Forge — API Open the app

Drive KB Forge from your own code

Same engine as the page: a resolved support ticket goes in, a publish-ready knowledge base article comes out — verdict, type, searchable title, category, tags, audience, confidence, the article body, and the editor's publishing notes and gaps. Every example below is tabbed across eight languages.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every response is an envelope: {"ok":true,"data":…} on success, {"ok":false,"error":{"code":…,"message":…}} on failure.

The routes are not what they look like. There is no /apps/kb-forge/ path segment anywhere. The endpoints are /guest, /me, /estimate, /run, /jobs/{id} and /run-stream; the slug is bound to the token when you mint it at /guest. Guessing an app-scoped path returns 404 not_found.

The run body is the input object directly, not {"input": {…}}.

CodeMeaningWhat to do
unauthorizedMissing or stale tokenMint a new one at /guest, or sign in on the token page.
not_foundWrong path — usually an invented /apps/… segmentUse the six endpoints listed above.
payment_requiredBalance below min_creditsCall /estimate first and compare against /me; top up before running.
validation_errorInput shape rejectedCheck the field table in step 3 — atype and audience are closed sets.
rate_limitedToo many requestsBack off and retry; do not tight-loop the poll.

Step 0 — A tiny client

Twenty lines that every later step reuses. Nothing here is app-specific except the base URL.

# Every call is POST or GET against the app API with a bearer token.
# The slug is bound to the TOKEN at /guest - there is no /apps/kb-forge/ path segment.
BASE="https://api.skillsafe.ai/v1/app-api"

# Reads the token from your environment; see /tokens.html to copy one.
auth() { echo "Authorization: Bearer ${SKILLSAFE_TOKEN}"; }

api() {  # api METHOD PATH [JSON]
  if [ -z "$3" ]; then
    curl -sS -X "$1" "${BASE}$2" -H "$(auth)"
  else
    curl -sS -X "$1" "${BASE}$2" -H "$(auth)" -H "Content-Type: application/json" -d "$3"
  fi
}
import json, os, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")

def api(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    if data:
        req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as r:
        payload = json.loads(r.read())
    if not payload.get("ok"):
        raise RuntimeError(payload.get("error"))
    return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // or read it from your own config

async function api(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const payload = await res.json();
  if (!payload.ok) throw new Error(payload.error?.message || res.statusText);
  return payload.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/http"
)

const base = "https://api.skillsafe.ai/v1/app-api"

var token = "YOUR_TOKEN" // or load it from your own config

type envelope struct {
	OK    bool            `json:"ok"`
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func api(method, path string, body any) (json.RawMessage, error) {
	var rdr *bytes.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
	} else {
		rdr = bytes.NewReader(nil)
	}
	req, err := http.NewRequest(method, base+path, rdr)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+token)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	if !env.OK {
		return nil, errors.New(env.Error.Message)
	}
	return env.Data, nil
}
import java.net.URI;
import java.net.http.*;

public class KbForge {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static String token = "YOUR_TOKEN"; // or load it from your own config
  static final HttpClient http = HttpClient.newHttpClient();

  static String api(String method, String path, String jsonBody) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + token);
    if (jsonBody == null) {
      b.method(method, HttpRequest.BodyPublishers.noBody());
    } else {
      b.header("Content-Type", "application/json")
       .method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
    }
    HttpResponse<String> res = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
    return res.body(); // an {"ok":true,"data":...} / {"ok":false,"error":...} envelope
  }
}
require "json"
require "net/http"
require "uri"

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")

def api(method, path, body = nil)
  uri = URI(BASE + path)
  klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  if body
    req["Content-Type"] = "application/json"
    req.body = JSON.dump(body)
  end
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise payload["error"].to_s unless payload["ok"]
  payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";

function api(string $method, string $path, ?array $body = null) {
  global $TOKEN;
  $headers = ["Authorization: Bearer $TOKEN"];
  if ($body !== null) { $headers[] = "Content-Type: application/json"; }
  $ch = curl_init(BASE . $path);
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => $method,
    CURLOPT_HTTPHEADER     => $headers,
    CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
  ]);
  $payload = json_decode(curl_exec($ch), true);
  curl_close($ch);
  if (empty($payload["ok"])) { throw new RuntimeException(json_encode($payload["error"] ?? null)); }
  return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class KbForge {
  const string Base = "https://api.skillsafe.ai/v1/app-api";
  static string Token = "YOUR_TOKEN"; // or load it from your own config
  static readonly HttpClient Http = new HttpClient();

  public static async Task<JsonElement> Api(HttpMethod method, string path, object body = null) {
    var req = new HttpRequestMessage(method, Base + path);
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
    if (body != null)
      req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    var res = await Http.SendAsync(req);
    var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!payload.GetProperty("ok").GetBoolean())
      throw new Exception(payload.GetProperty("error").ToString());
    return payload.GetProperty("data");
  }
}

Step 1 — Get a token

A guest token needs no credentials and is enough for /me and /estimate. A run needs credits, which means a personal token: sign in from the token page and copy it from there rather than digging in DevTools. Treat a token like a password.

# A guest token is minted without any credentials. The slug goes HERE, and
# nowhere else - it is bound to the token that comes back.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"kb-forge"}'

# {"ok":true,"data":{"token":"aut_...","subject_type":"guest",...}}
# Export it for the rest of this tutorial:
export SKILLSAFE_TOKEN="aut_..."
import json, urllib.request

req = urllib.request.Request(
    "https://api.skillsafe.ai/v1/app-api/guest",
    data=json.dumps({"slug": "kb-forge"}).encode(),
    headers={"Content-Type": "application/json"},
    method="POST")
with urllib.request.urlopen(req) as r:
    TOKEN = json.loads(r.read())["data"]["token"]

print(TOKEN)  # aut_...  - a GUEST token; sign-in gives a personal one
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "kb-forge" })
});
TOKEN = (await res.json()).data.token;

// In the browser the bundled SDK does this for you: SkillSafe.init({slug:"kb-forge"}).guest()
body := bytes.NewBufferString(`{"slug":"kb-forge"}`)
res, err := http.Post(base+"/guest", "application/json", body)
if err != nil {
	panic(err)
}
defer res.Body.Close()

var env struct {
	Data struct {
		Token string `json:"token"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
token = env.Data.Token
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"kb-forge\"}"))
    .build();
String body = http.send(req, HttpResponse.BodyHandlers.ofString()).body();
// body: {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
// Parse it with your JSON library of choice and assign it to `token`.
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ slug: "kb-forge" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }

token = JSON.parse(res.body)["data"]["token"]
<?php
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST           => true,
  CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
  CURLOPT_POSTFIELDS     => json_encode(["slug" => "kb-forge"]),
]);
$TOKEN = json_decode(curl_exec($ch), true)["data"]["token"];
curl_close($ch);
var body = new StringContent("{\"slug\":\"kb-forge\"}", Encoding.UTF8, "application/json");
var res = await Http.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", body);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Token = doc.RootElement.GetProperty("data").GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me returns subject_type (guest or user), subject_id and credits. Compare credits against the estimate before you run, so a 402 never arrives after the work has been prepared.

api GET /me

# {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}
# subject_type is "user" once you sign in; credits is the wallet balance.
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
raw, err := api("GET", "/me", nil)
if err != nil {
	panic(err)
}
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
json.Unmarshal(raw, &me)
String me = api("GET", "/me", null);
// {"ok":true,"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}
me = api("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Api(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type") + " " + me.GetProperty("credits"));

Step 3 — Estimate the cost

POST /estimate is free, creates no job and charges nothing. It returns hold_credits (what is reserved, priced against the full output cap), min_credits (below which a run cannot start at all), model, model_alias and markup_bps.

Between min_credits and hold_credits a run still executes, with a reduced output cap, and comes back with truncated: true. Handle that as a cut response, not as a finished one.

The input object

FieldTypeNotes
sourcestring, requiredThe raw material verbatim: a resolved ticket thread, a recurring question with its answer, workaround notes. The app clips past 30,000 characters by removing the middle and keeping both ends, and announces the cut in-band.
contextstring, optionalEditor context: KB taxonomy, audience, ticket volume, existing related articles, and anything marked internal-only — which the prompt keeps out of the article entirely.
atypeenumauto, howto, troubleshooting, faq, known-issue, reference. auto lets the model choose and say why.
audienceenumall, admins, developers. Sets the assumed technical depth.
srcscanstring, optionalA one-line summary of a mechanical scan of the source. The prompt treats it as an untrusted hint to verify against the source, never as fact.
retry_notestring, optionalSent only by the app's automatic second attempt when a reply did not parse. It corrects formatting only and never changes what the article says. You will not normally send it.
# estimate creates NO job and charges NOTHING. The body is the input object
# directly - not {"input": {...}}.
api POST /estimate '{
  "source": "Ticket #48213 - bulk export fails ... Error 429: rate limit exceeded ...",
  "context": "KB category: Data Export > Troubleshooting",
  "atype": "auto",
  "audience": "admins",
  "srcscan": "type-guess=Troubleshooting; steps=5"
}'

# {"ok":true,"data":{"hold_credits":1569,"min_credits":140,
#                    "model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                    "markup_bps":1000,"sponsor_enabled":false}}
article_input = {
    # The raw material, verbatim. The app clips anything over 30,000 chars from
    # the MIDDLE and keeps both ends - a ticket carries the symptom at the top
    # and the resolution at the bottom.
    "source": open("ticket-48213.txt").read(),
    # Optional editor context: KB taxonomy, audience, existing articles, and
    # anything marked internal-only (which is kept OUT of the article).
    "context": "KB category: Data Export > Troubleshooting\ninternal: do not mention the shard issue",
    # auto | howto | troubleshooting | faq | known-issue | reference
    "atype": "auto",
    # all | admins | developers
    "audience": "admins",
    # Optional. A one-line summary of a mechanical browser-side scan. The prompt
    # treats it as an untrusted hint to verify, never as fact. Omit it happily.
    "srcscan": "type-guess=Troubleshooting; steps=5; questions=1"
}

est = api("POST", "/estimate", article_input)
print(est["hold_credits"], est["min_credits"], est["model_alias"])

# hold_credits is RESERVED, not charged: it prices the full output cap. The
# settled charge is usually far lower. Below min_credits the run cannot start.
const articleInput = {
  source: ticketThreadText,          // verbatim; clipped middle-out past 30,000 chars
  context: "KB category: Data Export > Troubleshooting",
  atype: "auto",                     // auto|howto|troubleshooting|faq|known-issue|reference
  audience: "admins",                // all|admins|developers
  srcscan: "type-guess=Troubleshooting; steps=5"   // optional hint, verified by the prompt
};

const est = await api("POST", "/estimate", articleInput);
console.log(est.hold_credits, est.min_credits, est.model_alias);
input := map[string]any{
	"source":   ticketThread, // verbatim; clipped middle-out past 30,000 chars
	"context":  "KB category: Data Export > Troubleshooting",
	"atype":    "auto",
	"audience": "admins",
	"srcscan":  "type-guess=Troubleshooting; steps=5",
}

raw, err := api("POST", "/estimate", input)
if err != nil {
	panic(err)
}
var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	ModelAlias  string `json:"model_alias"`
}
json.Unmarshal(raw, &est)
String input = """
    {"source": %s,
     "context": "KB category: Data Export > Troubleshooting",
     "atype": "auto",
     "audience": "admins",
     "srcscan": "type-guess=Troubleshooting; steps=5"}
    """.formatted(jsonQuote(ticketThread));

String est = api("POST", "/estimate", input);
// {"hold_credits":1569,"min_credits":140,"model":"gpt-5.6-terra",
//  "model_alias":"gpt-terra","markup_bps":1000}
article_input = {
  source: File.read("ticket-48213.txt"),  # verbatim; clipped middle-out past 30,000 chars
  context: "KB category: Data Export > Troubleshooting",
  atype: "auto",                          # auto|howto|troubleshooting|faq|known-issue|reference
  audience: "admins",                     # all|admins|developers
  srcscan: "type-guess=Troubleshooting; steps=5"
}

est = api("POST", "/estimate", article_input)
puts est["hold_credits"], est["min_credits"], est["model_alias"]
<?php
$articleInput = [
  "source"   => file_get_contents("ticket-48213.txt"),
  "context"  => "KB category: Data Export > Troubleshooting",
  "atype"    => "auto",
  "audience" => "admins",
  "srcscan"  => "type-guess=Troubleshooting; steps=5",
];

$est = api("POST", "/estimate", $articleInput);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model_alias"], "\n";
var articleInput = new {
  source   = File.ReadAllText("ticket-48213.txt"),
  context  = "KB category: Data Export > Troubleshooting",
  atype    = "auto",
  audience = "admins",
  srcscan  = "type-guess=Troubleshooting; steps=5"
};

var est = await Api(HttpMethod.Post, "/estimate", articleInput);
Console.WriteLine(est.GetProperty("hold_credits") + " " + est.GetProperty("model_alias"));

Step 4 — Run it and poll

POST /run is metered and returns {"job_id"}; poll GET /jobs/{id} until status is succeeded, failed or cancelled. The terminal job carries output.output (the article text), charged_credits (the actual cost, usually well under the hold) and truncated.

Send an Idempotency-Key on every run. It makes one deliberate attempt billable exactly once, so a proxy replay or a transport retry cannot charge twice. It is not a "never pay twice for the same text" guarantee and must not be built as one: a key derived from the input alone makes the platform replay the first answer forever, so a deliberate re-run of unchanged source silently never runs. Derive it from the content plus a counter you increment per deliberate attempt, and give an automatic reformat retry its own suffix so it is not deduped against the attempt it is correcting.

# /run is metered. The body is the input object directly.
# Idempotency-Key: one deliberate attempt is billed once. Change the trailing
# counter when you WANT a fresh article for the same source.
HASH=$(printf '%s' "$SOURCE" | shasum -a 256 | cut -c1-12)
ATTEMPT=1

curl -sS -X POST "${BASE}/run" \
  -H "$(auth)" -H "Content-Type: application/json" \
  -H "Idempotency-Key: kb-forge-${HASH}-g${ATTEMPT}" \
  -d @input.json
# {"ok":true,"data":{"job_id":"job_..."}}

# Poll to a terminal state:
api GET /jobs/job_...
# {"ok":true,"data":{"status":"succeeded","output":{"output":"STATUS: ..."},
#                    "charged_credits":412,"truncated":false}}
import hashlib, time

attempt = 1
# The Idempotency-Key makes ONE press of your "generate" action billable once,
# even if the request is replayed by a proxy or a retry. It must CHANGE when you
# deliberately want a fresh article for the same input - a key that is only a
# content hash replays the first answer forever. Derive it from the content plus
# a counter you increment per deliberate attempt.
key = "kb-forge-%s-g%d" % (hashlib.sha256(
    (article_input["source"] + article_input["context"]).encode()).hexdigest()[:12], attempt)

job = api("POST", "/run", article_input)   # send Idempotency-Key: key with it
while True:
    j = api("GET", "/jobs/" + job["job_id"])
    if j["status"] in ("succeeded", "failed", "cancelled"):
        break
    time.sleep(1)

text = j["output"]["output"]      # the tagged plain-text article contract
charged = j["charged_credits"]    # the ACTUAL cost, usually well under the hold
truncated = j.get("truncated")    # True => the response was cut short
const key = `kb-forge-${await sha256Hex(articleInput.source)}-g${attempt}`;

const res = await fetch(`${BASE}/run`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key
  },
  body: JSON.stringify(articleInput)
});
const { job_id } = (await res.json()).data;

let job;
do {
  await new Promise(r => setTimeout(r, 1000));
  job = await api("GET", `/jobs/${job_id}`);
} while (!["succeeded", "failed", "cancelled"].includes(job.status));

const text = job.output.output;
// Send the Idempotency-Key header with the /run POST:
//   req.Header.Set("Idempotency-Key", fmt.Sprintf("kb-forge-%s-g%d", hash, attempt))
raw, err := api("POST", "/run", input)
if err != nil {
	panic(err)
}
var started struct {
	JobID string `json:"job_id"`
}
json.Unmarshal(raw, &started)

for {
	raw, err = api("GET", "/jobs/"+started.JobID, nil)
	if err != nil {
		panic(err)
	}
	var job struct {
		Status         string `json:"status"`
		ChargedCredits int64  `json:"charged_credits"`
		Truncated      bool   `json:"truncated"`
		Output         struct {
			Output string `json:"output"`
		} `json:"output"`
	}
	json.Unmarshal(raw, &job)
	if job.Status == "succeeded" || job.Status == "failed" || job.Status == "cancelled" {
		break
	}
	time.Sleep(time.Second)
}
// Add the idempotency header to the /run POST:
//   .header("Idempotency-Key", "kb-forge-" + hash + "-g" + attempt)
String started = api("POST", "/run", input);   // {"data":{"job_id":"job_..."}}

String job;
do {
  Thread.sleep(1000);
  job = api("GET", "/jobs/" + jobId, null);
} while (!isTerminal(job));   // succeeded | failed | cancelled

// job.data.output.output holds the tagged article text.
require "digest"

attempt = 1
key = "kb-forge-#{Digest::SHA256.hexdigest(article_input[:source])[0, 12]}-g#{attempt}"
# Send it as the Idempotency-Key header on the /run POST.

started = api("POST", "/run", article_input)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed cancelled].include?(job["status"])
  sleep 1
end

text = job["output"]["output"]
<?php
$attempt = 1;
$key = "kb-forge-" . substr(hash("sha256", $articleInput["source"]), 0, 12) . "-g$attempt";
// Add "Idempotency-Key: $key" to the /run POST headers.

$started = api("POST", "/run", $articleInput);

do {
  sleep(1);
  $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed", "cancelled"], true));

$text = $job["output"]["output"];
var key = $"kb-forge-{Sha256Hex(articleInput.source)[..12]}-g{attempt}";
// req.Headers.Add("Idempotency-Key", key) on the /run POST.

var started = await Api(HttpMethod.Post, "/run", articleInput);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
string status;
do {
  await Task.Delay(1000);
  job = await Api(HttpMethod.Get, $"/jobs/{jobId}");
  status = job.GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed" && status != "cancelled");

var text = job.GetProperty("output").GetProperty("output").GetString();

Step 5 — The same run, streamed

POST /run-stream takes the same body and the same Idempotency-Key and emits server-sent events: a job frame, then delta frames, then a done frame. The done payload is authoritative — deltas can drop the tail, so use the concatenated deltas for live progress and the final frame for the result.

# Server-sent events. Same body, same Idempotency-Key rules.
curl -N -X POST "${BASE}/run-stream" \
  -H "$(auth)" -H "Content-Type: application/json" \
  -H "Idempotency-Key: kb-forge-${HASH}-g${ATTEMPT}" \
  -d @input.json

# event: job    data: {"job_id":"job_..."}
# event: delta  data: {"text":"STATUS: Publish after filling gaps\n"}
# event: delta  data: {"text":"TYPE: Troubleshooting\n"}
# ...
# event: done   data: {"charged_credits":412,"truncated":false,
#                      "output":{"output":"STATUS: ..."}}
#
# The `done` payload is authoritative - deltas can drop the tail. Concatenate
# deltas for live progress, then parse done.output.output.
import json, urllib.request

req = urllib.request.Request(
    BASE + "/run-stream",
    data=json.dumps(article_input).encode(),
    method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)

buf, done = "", None
with urllib.request.urlopen(req) as stream:
    for line in stream:
        line = line.decode().rstrip("\n")
        if not line.startswith("data: "):
            continue
        payload = json.loads(line[6:])
        if "text" in payload:
            buf += payload["text"]
        elif "output" in payload:
            done = payload

text = done["output"]["output"] if done else buf
const res = await fetch(`${BASE}/run-stream`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key
  },
  body: JSON.stringify(articleInput)
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", acc = "", done = null;
for (;;) {
  const { value, done: fin } = await reader.read();
  if (fin) break;
  buf += dec.decode(value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const f of frames) {
    const line = f.split("\n").find(l => l.startsWith("data: "));
    if (!line) continue;
    const p = JSON.parse(line.slice(6));
    if (p.text) acc += p.text;
    else if (p.output) done = p;
  }
}
const text = done ? done.output.output : acc;

// In the browser the SDK wraps all of this:
// ss.runStream(input, { idempotencyKey: key, onDelta, onJob })
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)

res, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var acc strings.Builder
for sc.Scan() {
	line := sc.Text()
	if !strings.HasPrefix(line, "data: ") {
		continue
	}
	var frame struct {
		Text   string `json:"text"`
		Output *struct {
			Output string `json:"output"`
		} `json:"output"`
	}
	json.Unmarshal([]byte(line[6:]), &frame)
	if frame.Output != nil {
		acc.Reset()
		acc.WriteString(frame.Output.Output)
	} else {
		acc.WriteString(frame.Text)
	}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(input))
    .build();

StringBuilder acc = new StringBuilder();
http.send(req, HttpResponse.BodyHandlers.ofLines()).body()
    .filter(l -> l.startsWith("data: "))
    .forEach(l -> acc.append(extractDelta(l.substring(6))));

// The final `done` frame carries output.output and charged_credits; prefer it
// over the concatenated deltas, which can drop the tail.
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.dump(article_input)

acc = ""
done = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        next unless line.start_with?("data: ")
        payload = JSON.parse(line[6..].strip)
        if payload["text"] then acc << payload["text"]
        elsif payload["output"] then done = payload
        end
      end
    end
  end
end

text = done ? done["output"]["output"] : acc
<?php
$ch = curl_init(BASE . "/run-stream");
$acc = "";
$done = null;
curl_setopt_array($ch, [
  CURLOPT_POST       => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer $TOKEN",
    "Content-Type: application/json",
    "Idempotency-Key: $key",
  ],
  CURLOPT_POSTFIELDS => json_encode($articleInput),
  CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$acc, &$done) {
    foreach (explode("\n", $chunk) as $line) {
      if (strpos($line, "data: ") !== 0) { continue; }
      $p = json_decode(substr($line, 6), true);
      if (isset($p["text"]))        { $acc .= $p["text"]; }
      elseif (isset($p["output"]))  { $done = $p; }
    }
    return strlen($chunk);
  },
]);
curl_exec($ch);
curl_close($ch);

$text = $done ? $done["output"]["output"] : $acc;
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(articleInput), Encoding.UTF8, "application/json");

var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

var acc = new StringBuilder();
string doneText = null;
string line;
while ((line = await reader.ReadLineAsync()) != null) {
  if (!line.StartsWith("data: ")) continue;
  var p = JsonDocument.Parse(line[6..]).RootElement;
  if (p.TryGetProperty("text", out var t)) acc.Append(t.GetString());
  else if (p.TryGetProperty("output", out var o)) doneText = o.GetProperty("output").GetString();
}

var text = doneText ?? acc.ToString();

The output contract

output.output is plain text in a tagged, sectioned shape. Eight single-line tags, then four ## sections in this exact order, and those are the only level-2 headings in the response.

STATUS: Publish as is | Publish after filling gaps | Needs SME review
TYPE: How-to | Troubleshooting | FAQ | Known issue | Reference
TITLE: <one line, sentence case, no trailing period>
CATEGORY: <one short line>
TAGS: <3 to 8 comma-separated lowercase tags>
AUDIENCE: All users | Admins | Developers
CONFIDENCE: <bare integer 0-100>
SUMMARY: <2 to 4 sentences, may wrap, ends at a blank line>

## Article
<markdown body; ### and #### headings only, never # or ##>

## Publishing notes
- <note for the editor; never empty>

## Gaps to fill
- <fact the source did not establish, or "- None.">

## Related articles
- <article to link, or "- None.">

Two rules worth enforcing on your side, because the app does: a non-empty Gaps to fill forbids Publish as is — treat that combination as a contradiction to surface, not to smooth over. And when truncated is true, the sections that never arrived are absent, which is not the same as - None.; the latter means the model looked and found nothing. Report a missing section as not received rather than as empty.

The article body carries the customer-facing text. Publishing notes, Gaps to fill and Related articles are internal: they name ticket numbers, customer names and unconfirmed facts, so keep them out of anything you publish.

Costs

Runs are metered against the calling subject's wallet at the app's model tier plus a 10% publisher markup (markup_bps: 1000). /guest, /me and /estimate are free and create no job. hold_credits is a reservation, not a price; charged_credits on the terminal job is what was actually spent.