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": {…}}.
| Code | Meaning | What to do |
|---|---|---|
unauthorized | Missing or stale token | Mint a new one at /guest, or sign in on the token page. |
not_found | Wrong path — usually an invented /apps/… segment | Use the six endpoints listed above. |
payment_required | Balance below min_credits | Call /estimate first and compare against /me; top up before running. |
validation_error | Input shape rejected | Check the field table in step 3 — atype and audience are closed sets. |
rate_limited | Too many requests | Back 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
| Field | Type | Notes |
|---|---|---|
source | string, required | The 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. |
context | string, optional | Editor context: KB taxonomy, audience, ticket volume, existing related articles, and anything marked internal-only — which the prompt keeps out of the article entirely. |
atype | enum | auto, howto, troubleshooting, faq, known-issue, reference. auto lets the model choose and say why. |
audience | enum | all, admins, developers. Sets the assumed technical depth. |
srcscan | string, optional | A 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_note | string, optional | Sent 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.