Drive SMS Desk from your own code
Everything the web page's two paid lanes do is available over HTTP. You send one JSON object with a
task (review or draft) and a facts string, and
the model replies with one JSON object: a verdict, per-message rewrites and HELP and STOP replies
for a review, or a complete flow with an opt-in disclosure for a draft.
Before the first call, note this: the model never counts characters or segments.
The page's free check runs entirely in your browser, in smskit.js: it reads the flow,
measures every message as it is billed (GSM-7 or UCS-2, segments, merge tags filled at their
assumed width) and raises findings against the rules in smsrules.js. That result is
what the page sends as facts. A direct API caller has no page, so
you build facts yourself, ideally with the same two files (see
building the facts).
Base URL and the envelope
Every endpoint below lives under https://api.skillsafe.ai/v1/app-api. A success
carries its payload in data; a failure has a non-2xx HTTP status and an
error object:
HTTP 2xx { "data": { ... } }
HTTP 4xx { "error": { "code": "...", "message": "...", "details": { ... } } }
This is how the vendored SDK (sdk.js) reads every response: when the status is not
2xx it raises an error carrying the HTTP status and error.code,
error.message and error.details; otherwise it returns data.
So branch on the HTTP status. Every call sends Content-Type: application/json and,
once you have a token, Authorization: Bearer YOUR_TOKEN. No slug header is sent: the
token was issued for this app.
The input object is the request body. The SDK posts
JSON.stringify(input) as the body of /estimate, /run and
/run-stream; there is no wrapper. The page guards itself with
Smskit.mustBeObject, which throws unless the input is a plain object. Do the same.
Error codes
Only the statuses the SDK or the page handle by name are listed. For anything else, log
error.code and error.message as given.
| status | what the page does | what to do |
|---|---|---|
| 401 | Treats it as "Your session expired": it forgets the signed-in user and asks for a new sign-in. | Get a fresh token from the Tokens page. |
| 402 | Shows "Not enough credits for this run" with a top-up link. The run button is disabled beforehand whenever the balance is below min_credits from /estimate, so the page does not submit into a 402. | Compare credits from /me with min_credits before you run; top up. |
| other non-2xx | Shows "The run failed: " followed by error.message. | Read error.code and error.message, fix the request. |
SSE error | On /run-stream, a failure after the stream opened arrives as an error event; the SDK throws with its message, code and job_id. | Log all three; the job_id identifies the failed job. |
The task field: two lanes
One system prompt serves both lanes, routed on task. The body has two required string
fields and two optional ones:
| field | type | meaning |
|---|---|---|
task | string, required | "review" or "draft". |
facts | string, required | A JSON-encoded string, not an object: the page sends JSON.stringify(facts). Its contents depend on the lane (below). |
question | string, optional | A free-text question for the model, answered in summary. The page trims it, sends it only when it is not empty and cuts it to 600 characters. |
retry_note | string, optional | Never sent on a normal run. The page adds it only on its one automatic retry after a reply it could not parse, asking for a single valid JSON object. You may use it the same way. |
review: check and rewrite a pasted flow
The marketer pastes an existing flow. The browser's free check measures and lints it, and
facts is exactly what that check computed (Smskit.buildFacts). The model
decides whether the flow can be sent, answers every block and warn
finding, rewrites the messages that need it and writes HELP and STOP replies. Its verdict may be
stricter than the browser's verdict_floor, never looser.
| facts field | content |
|---|---|
brand | The brand name the texts are signed with. |
jurisdictions | Array drawn from "US", "CA", "EU/UK", "AU". |
default_kind | The kind assumed for a message that does not name one (the page offers "marketing" or "transactional"). |
segment_budget | The most segments a message should take (1 to 10). |
disclosed_per_month, list_size, price_per_segment | Numbers, or null when not given. |
send_window | For example "09:00-20:00". |
recipient_zones | IANA time zones the recipients are in. |
merge_widths | Object of merge tag name to assumed width, for example {"first_name": 12}. |
messages[] | One per message: id ("M1"), label, kind (opt_in, marketing, transactional, otp, help, stop), kind_rule, timing, body, encoding (GSM-7 or UCS-2), chars, units, segments, worst_case_segments, merge_tags, links, flags (ids of its findings). |
flags[] | One per finding: id ("X1"), code, severity (block, warn, info), message (a message id, or null for the whole flow), rule (a short title), detail. |
counts | messages, marketing, segments, segments_worst, ucs2, block, warn, info. |
cost | {per_recipient, per_recipient_worst, total, total_worst} when a price is given (total fields null without a list size), otherwise null. |
verdict_floor | blocked when any block flag exists, fix_first when any warn flag exists, otherwise ready. |
notes | Up to 10 reading notes, plus one when messages were dropped. |
Limits the page applies while building these facts: it reads at most 60,000 characters of paste
and the first 40 messages, and a body longer than 900 characters is sent with its middle cut and
marked ([... N characters cut from the middle ...]). The counts are always measured
on the full body.
draft: write a flow from a brief
The marketer fills in a brief and facts is that brief (Smskit.readBrief).
The page will not build a draft input until the brand is set, flow_type is one of the
nine listed values and business is at least 12 characters.
| facts field | content |
|---|---|
brand | The brand name, up to 60 characters. |
business | What they sell and to whom, up to 600 characters. |
flow_type | welcome, abandoned_cart, browse_abandonment, post_purchase, win_back, promotional, transactional, back_in_stock or appointment_reminder. |
audience, offer | Up to 300 characters each; offer may be empty. |
merge_tags | Up to 8 tag names, the only tags the draft may use (default first_name, link). |
jurisdictions, segment_budget | As in review. |
gsm_only, include_opt_in | Booleans, both true unless set to false. |
disclosed_per_month | A number, or null. |
notes | Free text, up to 600 characters. |
The reply contract
A finished job carries the model's reply as text in output.output. The
system prompt asks for exactly one JSON object with no prose and no code fences, and arrays that
are present even when empty. The page parses it by trimming a code fence if there is one and
taking the text from the first { to the last }.
review reply:
{
"lane": "review",
"verdict": "ready" | "fix_first" | "blocked",
"headline": "...",
"messages": [
{
"id": "M1",
"verdict": "ok" | "fix" | "block",
"issues": [ { "flag": "X2", "note": "..." } ],
"rewrite": "full replacement text, or an empty string",
"why": "...",
"target_segments": 1,
"target_encoding": "GSM-7" | "UCS-2"
}
],
"flow_notes": [ { "topic": "timing", "flags": ["X9"], "note": "..." } ],
"a2p_samples": ["M1"],
"help_reply": "...",
"stop_reply": "...",
"summary": "..."
}
How the page reads it (Recon.normalizeReview): a verdict outside the three
values is read as blocked; a message whose id is not M plus
digits is dropped; an unknown message verdict is read as fix; issue flag
ids must be X plus digits; flow_notes topics are timing,
frequency, sequence, offer, compliance,
registration, cost or copy (anything else is read as
copy); a reply with no messages array, no headline and no
summary is rejected.
draft reply:
{
"lane": "draft",
"flow_name": "...",
"trigger": "...",
"headline": "...",
"messages": [
{
"label": "Send 1",
"kind": "opt_in" | "marketing" | "transactional",
"delay": "+30m",
"body": "...",
"purpose": "...",
"target_segments": 1
}
],
"opt_in_disclosure": "...",
"help_reply": "...",
"stop_reply": "...",
"tests": ["..."],
"notes": ["..."],
"summary": "..."
}
How the page reads it (Recon.normalizeDraft): messages get ids M1,
M2, ... by position; a message with an empty body is dropped;
label is cut to 80 characters and delay to 40; an unknown
kind is read as marketing; a reply with no messages, no
headline and no summary is rejected.
What the page adds, and what you get
The page re-measures and re-checks every text the model writes. For a review it
confirms every message was reviewed, that every block and warn finding is
answered where it belongs, measures each rewrite again (encoding, segments, against the
target_segments and target_encoding the model claimed and against the
segment budget), lints it again with the same rules, checks merge tags were kept, checks every
count written in prose against the measured counts, holds the verdict to
verdict_floor, and checks the HELP and STOP replies. For a draft it measures and lints
every drafted message, checks gsm_only, the merge-tag list, the delays, the opt-in
message of a welcome flow, the disclosure's contents and the replies. Each disagreement is shown
to the marketer.
An API caller gets the raw reply without that reconciliation. The job returns only
the model's text. If you need the same checks, load smsrules.js,
smskit.js and recon.js (in Node, set global.SmsRules and
global.Smskit first, as below), parse the reply with Recon.parseResult(text),
normalize it with Recon.normalizeReview or Recon.normalizeDraft, and call
Recon.reconcileReview(res, facts) or Recon.reconcileDraft(res, facts)
with facts as the parsed object. Each returns {items, statements, disagreements}
among other fields; every item has kind, text and ok.
Building the facts
smsrules.js, smskit.js and recon.js are the files the page
loads from https://sms-desk.skillsafe.ai/. They are plain functions with no DOM, and each
also exports itself through module.exports, so Node can use them directly.
smskit.js looks for the rules on global.SmsRules. This builds both worked
example bodies below, byte for byte:
// Node 18+, with smsrules.js and smskit.js saved next to this file.
global.SmsRules = require("./smsrules.js");
const Smskit = require("./smskit.js");
// review: analyze the flow text, then buildFacts
const flow = "[Shipped | +0 | transactional]\n" +
"Brackwold: Your order {{ order_number }} has shipped. Track it: brackwold.com/track Reply HELP for help, STOP to opt out.\n\n" +
"[Review ask | +5d | marketing]\n" +
"Brackwold: How are the new boots, {{ first_name }}? Tell us in 30 seconds: brackwold.com/review Reply STOP to opt out\n\n" +
"[HELP reply | help]\n" +
"Brackwold: Help at brackwold.com/help or 1-800-555-0142. Msg & data rates may apply. Reply STOP to cancel.\n\n" +
"[STOP reply | stop]\n" +
"Brackwold: You're unsubscribed and will not receive more messages. Reply HELP for help.\n";
const P = Smskit.analyze(flow, {
brand: "Brackwold", jur: { us: true }, segment_budget: "1",
widths: "order_number=10, first_name=12", window_start: "09:00", window_end: "20:00",
});
const review = Smskit.buildInput("review", Smskit.buildFacts(P), "");
// draft: readBrief returns { errors, facts }; run only when errors is empty
const brief = Smskit.readBrief({
brand: "Tansyra Skin", jur: { us: true, ca: true }, segment_budget: "1", per_month: "4",
business: "Tansyra Skin sells fragrance-free skincare for sensitive skin, online only, to adults in the US and Canada.",
flow_type: "win_back",
audience: "Customers whose last order was 90 or more days ago and who opted in to texts.",
offer: "20% off the next order with code COMEBACK20, valid for 7 days from the first win-back text.",
merge_tags: "first_name, link", gsm_only: true, include_opt_in: false,
notes: "Friendly, not pushy. The link tag goes to a personalised reorder page.",
});
if (brief.errors.length) throw new Error(brief.errors.join(" "));
const draft = Smskit.buildInput("draft", brief.facts, "");
require("node:fs").writeFileSync("review.json", JSON.stringify(review));
require("node:fs").writeFileSync("draft.json", JSON.stringify(draft));
buildInput is where facts becomes a string and where an empty
question is left out. Building facts in another language is possible, but
every count and finding must then match what smskit.js would compute, because the
model is told those counts are authoritative.
Worked examples: one body per lane
These are the exact bodies the page sends for its two built-in examples (the Node snippet above
rebuilds them). The outer object is pretty-printed here; facts stays one JSON string.
Save them as review.json and draft.json: the steps below read the body
from a file and send its bytes unchanged.
review: Brackwold shipping and review ask
Four messages, two info findings (X1 on M2, X2
for the whole flow) and verdict_floor ready. No question, so
none is sent.
{
"task": "review",
"facts": "{\"brand\":\"Brackwold\",\"jurisdictions\":[\"US\"],\"default_kind\":\"marketing\",\"segment_budget\":1,\"disclosed_per_month\":null,\"list_size\":null,\"price_per_segment\":null,\"send_window\":\"09:00-20:00\",\"recipient_zones\":[\"America/New_York\",\"America/Chicago\",\"America/Denver\",\"America/Phoenix\",\"America/Los_Angeles\",\"America/Anchorage\",\"Pacific/Honolulu\"],\"merge_widths\":{\"order_number\":10,\"first_name\":12},\"messages\":[{\"id\":\"M1\",\"label\":\"Shipped\",\"kind\":\"transactional\",\"kind_rule\":\"header\",\"timing\":\"T+0\",\"body\":\"Brackwold: Your order {{ order_number }} has shipped. Track it: brackwold.com/track Reply HELP for help, STOP to opt out.\",\"encoding\":\"GSM-7\",\"chars\":113,\"units\":113,\"segments\":1,\"worst_case_segments\":1,\"merge_tags\":[\"order_number\"],\"links\":[\"brackwold.com/track\"],\"flags\":[]},{\"id\":\"M2\",\"label\":\"Review ask\",\"kind\":\"marketing\",\"kind_rule\":\"header\",\"timing\":\"T+5d\",\"body\":\"Brackwold: How are the new boots, {{ first_name }}? Tell us in 30 seconds: brackwold.com/review Reply STOP to opt out\",\"encoding\":\"GSM-7\",\"chars\":113,\"units\":113,\"segments\":1,\"worst_case_segments\":2,\"merge_tags\":[\"first_name\"],\"links\":[\"brackwold.com/review\"],\"flags\":[\"X1\"]},{\"id\":\"M3\",\"label\":\"HELP reply\",\"kind\":\"help\",\"kind_rule\":\"header\",\"timing\":\"unknown\",\"body\":\"Brackwold: Help at brackwold.com/help or 1-800-555-0142. Msg & data rates may apply. Reply STOP to cancel.\",\"encoding\":\"GSM-7\",\"chars\":106,\"units\":106,\"segments\":1,\"worst_case_segments\":1,\"merge_tags\":[],\"links\":[\"brackwold.com/help\"],\"flags\":[]},{\"id\":\"M4\",\"label\":\"STOP reply\",\"kind\":\"stop\",\"kind_rule\":\"header\",\"timing\":\"unknown\",\"body\":\"Brackwold: You're unsubscribed and will not receive more messages. Reply HELP for help.\",\"encoding\":\"GSM-7\",\"chars\":87,\"units\":87,\"segments\":1,\"worst_case_segments\":1,\"merge_tags\":[],\"links\":[],\"flags\":[]}],\"flags\":[{\"id\":\"X1\",\"code\":\"merge_ucs2_risk\",\"severity\":\"info\",\"message\":\"M2\",\"rule\":\"A merged name can switch encoding\",\"detail\":\"A merged value with one non-GSM letter (a name such as Zoë) would switch this message to UCS-2: 2 segment(s) instead of 1.\"},{\"id\":\"X2\",\"code\":\"trigger_timing\",\"severity\":\"info\",\"message\":null,\"rule\":\"Triggered sends need a quiet-hour hold\",\"detail\":\"1 marketing message(s) go out on a delay after the trigger (M2 T+5d), so the send hour depends on when the trigger fires; quiet hours must be enforced by the platform.\"}],\"counts\":{\"messages\":4,\"marketing\":1,\"segments\":4,\"segments_worst\":5,\"ucs2\":0,\"block\":0,\"warn\":0,\"info\":2},\"cost\":null,\"verdict_floor\":\"ready\",\"notes\":[]}"
}
The reply follows the review contract: one entry in messages
for each of M1 to M4, in order, with verdict no looser than
ready. Only block and warn findings must be answered, so
X1 and X2 may be answered or left alone.
draft: Tansyra Skin win-back
A win_back brief with an offer, gsm_only true, a one-segment budget and two
allowed merge tags.
{
"task": "draft",
"facts": "{\"brand\":\"Tansyra Skin\",\"business\":\"Tansyra Skin sells fragrance-free skincare for sensitive skin, online only, to adults in the US and Canada.\",\"flow_type\":\"win_back\",\"audience\":\"Customers whose last order was 90 or more days ago and who opted in to texts.\",\"offer\":\"20% off the next order with code COMEBACK20, valid for 7 days from the first win-back text.\",\"merge_tags\":[\"first_name\",\"link\"],\"jurisdictions\":[\"US\",\"CA\"],\"segment_budget\":1,\"gsm_only\":true,\"disclosed_per_month\":4,\"include_opt_in\":false,\"notes\":\"Friendly, not pushy. The link tag goes to a personalised reorder page.\"}"
}
The reply follows the draft contract. The system prompt asks for two to five
messages in send order, each body starting with Tansyra Skin:, only the
first_name and link tags, and two to four tests.
1. A tiny client
One helper that sends an already-serialised JSON body with Content-Type: application/json,
adds Authorization: Bearer ... when a token is set (the SDK does the same: no token,
no header), unwraps data and raises on a non-2xx status with error.code
and error.message. It takes the body as a string so a run can derive its
key from the exact bytes it sends (step 5). The later steps assume this helper is in scope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://sms-desk.skillsafe.ai/tokens.html
# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero (curl --fail-with-body) on a 4xx/5xx.
api() {
local method="$1" path="$2" body="${3:-}"
shift 2; [ $# -gt 0 ] && shift
local auth=(); [ -n "$TOKEN" ] && auth=(-H "Authorization: Bearer $TOKEN")
if [ -n "$body" ]; then
curl -sS --fail-with-body -X "$method" "$BASE$path" "${auth[@]}" \
-H "Content-Type: application/json" --data-binary "@$body" "$@"
else
curl -sS --fail-with-body -X "$method" "$BASE$path" "${auth[@]}" \
-H "Content-Type: application/json" "$@"
fi
}
import json, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://sms-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 call(method, path, body=None, headers=None):
"""body is an already-serialised JSON string (or None). Returns `data`."""
req = urllib.request.Request(BASE + path, method=method,
data=body.encode("utf-8") if body is not None else None)
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:
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r).get("data")
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
// Node 18+ as an ES module (.mjs, for top-level await).
const BASE = "https://api.skillsafe.ai/v1/app-api";
let token = "YOUR_TOKEN"; // from https://sms-desk.skillsafe.ai/tokens.html
// body is an already-serialised JSON string (or undefined). Returns `data`.
async function call(method, path, body, headers = {}) {
const h = { "Content-Type": "application/json", ...headers };
if (token) h.Authorization = "Bearer " + token;
const res = await fetch(BASE + path, { method, headers: h, body });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
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;
throw err;
}
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://sms-desk.skillsafe.ai/tokens.html
var _ = os.ReadFile // the later snippets read the body file with os.ReadFile
// The later snippets are functions in this same package; call them from your main().
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
// call sends one request (body is serialised JSON or nil) and returns the `data` member.
func call(method, path string, body []byte, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, base+path, rdr)
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 extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return nil, e
}
return env.Data, nil
}
// Java 17+, Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
// The later snippets are static members of this class; plain statements go in main().
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class SmsDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String TOKEN = "YOUR_TOKEN"; // from https://sms-desk.skillsafe.ai/tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiException extends Exception {
final int status;
final String code;
ApiException(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
}
}
/** body is serialised JSON or null. Returns the `data` member. */
static JsonNode call(String method, String path, String body, Map<String, String> extra) throws Exception {
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);
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (res.statusCode() < 200 || res.statusCode() > 299) {
JsonNode err = env.path("error");
throw new ApiException(res.statusCode(), err.path("code").asText(null),
err.path("message").asText("HTTP " + res.statusCode()));
}
return env.path("data");
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
$token = "YOUR_TOKEN" # from https://sms-desk.skillsafe.ai/tokens.html
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
# body is an already-serialised JSON string (or nil). Returns `data`.
def call(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = (method == "GET" ? Net::HTTP::Get : Net::HTTP::Post).new(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
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message)
end
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://sms-desk.skillsafe.ai/tokens.html
class ApiError extends RuntimeException {
public function __construct(public int $status, public ?string $errCode, string $message) {
parent::__construct("$status $errCode: $message");
}
}
/** $body is an already-serialised JSON string (or null). Returns `data` as an array. */
function call(string $method, string $path, ?string $body = null, array $headers = []) {
global $TOKEN;
$ch = curl_init(BASE . $path);
$h = ["Content-Type: application/json"];
if ($TOKEN !== "") $h[] = "Authorization: Bearer " . $TOKEN;
foreach ($headers as $k => $v) $h[] = "$k: $v";
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $h,
CURLOPT_RETURNTRANSFER => true,
]);
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);
curl_close($ch);
$env = json_decode($raw, true) ?: [];
if ($status < 200 || $status > 299) {
$err = $env["error"] ?? [];
throw new ApiError($status, $err["code"] ?? null, $err["message"] ?? "HTTP $status");
}
return $env["data"] ?? null;
}
// .NET 8. Api.cs: keep it in its own file (C# wants types after top-level statements).
// The later snippets are top-level statements and local functions in Program.cs.
global using System.Net.Http.Headers;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Nodes;
public class ApiException(int status, string? code, string? message)
: Exception($"{status} {code}: {message}")
{
public int Status => status;
public string? Code => code;
}
public static class Api
{
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://sms-desk.skillsafe.ai/tokens.html
public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
// body is serialised JSON or null. Returns the `data` member.
public static async Task<JsonNode?> Call(HttpMethod method, string path, string? body = null,
IDictionary<string, string>? extra = null)
{
using var req = new HttpRequestMessage(method, Base + path);
if (!string.IsNullOrEmpty(Token))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body is not null) req.Content = new StringContent(body, Encoding.UTF8, "application/json");
if (extra is not null)
foreach (var (k, v) in extra) req.Headers.TryAddWithoutValidation(k, v);
using var res = await Http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (!res.IsSuccessStatusCode)
throw new ApiException((int)res.StatusCode, (string?)env?["error"]?["code"],
(string?)env?["error"]?["message"] ?? res.ReasonPhrase);
return env?["data"];
}
}
2. Get a token
The easiest route is the Tokens page. It shows the token this browser
already holds for sms-desk, with Copy token and
Copy shell export buttons, and a Sign in with SkillSafe button for a
personal token. It reads the same storage the app uses, so you never need a developer tool.
There are two kinds of token. A guest token is what the page starts with: the SDK
mints one with POST /guest and the body {"slug":"sms-desk"}, sent without
an Authorization header, and reads data.token (and
data.guest_id) from the reply. The page then calls /me and
/estimate with it. Reviewing or drafting is metered: unless /estimate
reports sponsor_enabled, the page tells a guest "This review is metered, so it needs an
account" and asks them to sign in. A personal token comes from signing in on the
Tokens page and bills your own balance. Treat it like a password.
# Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html,
# press "Copy token" and paste it into TOKEN.
#
# Guest token, minted from the command line (no Authorization header):
TOKEN=$(curl -sS -X POST "$BASE/guest" -H "Content-Type: application/json" \
-d '{"slug":"sms-desk"}' | jq -r '.data.token')
# Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into TOKEN.
# Guest token:
TOKEN = "" # no token yet, so call() sends no Authorization
guest = call("POST", "/guest", json.dumps({"slug": "sms-desk"}))
TOKEN = guest["token"] # guest["guest_id"] names the guest
// Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into token.
// Guest token:
token = ""; // no token yet, so call() sends no Authorization
const guest = await call("POST", "/guest", JSON.stringify({ slug: "sms-desk" }));
token = guest.token; // guest.guest_id names the guest
// Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into token.
// Guest token:
func guestToken() error {
token = "" // no token yet, so call() sends no Authorization
data, err := call("POST", "/guest", []byte(`{"slug":"sms-desk"}`), nil)
if err != nil {
return err
}
var g struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
if err := json.Unmarshal(data, &g); err != nil {
return err
}
token = g.Token
return nil
}
// Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into TOKEN.
// Guest token:
static void guestToken() throws Exception {
TOKEN = ""; // no token yet, so call() sends no Authorization
JsonNode g = call("POST", "/guest", "{\"slug\":\"sms-desk\"}", null);
TOKEN = g.path("token").asText(); // g.path("guest_id") names the guest
}
# Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into $token.
# Guest token:
$token = "" # no token yet, so call sends no Authorization
guest = call("POST", "/guest", JSON.generate({ slug: "sms-desk" }))
$token = guest["token"] # guest["guest_id"] names the guest
<?php
// Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into $TOKEN.
// Guest token:
$TOKEN = ""; // no token yet, so call() sends no Authorization
$guest = call("POST", "/guest", json_encode(["slug" => "sms-desk"]));
$TOKEN = $guest["token"]; // $guest["guest_id"] names the guest
// Personal token: sign in at https://sms-desk.skillsafe.ai/tokens.html and copy it into Api.Token.
// Guest token:
Api.Token = ""; // no token yet, so Call() sends no Authorization
var guest = await Api.Call(HttpMethod.Post, "/guest", "{\"slug\":\"sms-desk\"}");
Api.Token = (string)guest!["token"]!; // guest["guest_id"] names the guest
3. Check the session and the balance
GET /me returns {subject_type, subject_id, credits, profile?}. The page
treats subject_type "user" as signed in and reads credits to
show the balance and to gate the run button (step 4). Credits convert to dollars at
10,000 credits per dollar, as the page displays them.
api GET /me | jq '.data | {subject_type, credits}'
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
signed_in = me["subject_type"] == "user"
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
const signedIn = me.subject_type === "user";
type Me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits *float64 `json:"credits"`
}
func whoAmI() (*Me, error) {
data, err := call("GET", "/me", nil, nil)
if err != nil {
return nil, err
}
var me Me
return &me, json.Unmarshal(data, &me)
}
JsonNode me = call("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits").asText());
boolean signedIn = "user".equals(me.path("subject_type").asText());
me = call("GET", "/me")
puts "#{me['subject_type']} #{me['credits']}"
signed_in = me["subject_type"] == "user"
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", PHP_EOL;
$signedIn = $me["subject_type"] === "user";
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me?["subject_type"]} {me?["credits"]}");
var signedIn = (string?)me?["subject_type"] == "user";
4. Price it (free)
POST /estimate takes the same body you will run and returns the worst-case credit cost
of that run. It charges nothing and creates no job, so call it as often as you like: the page
re-prices on every edit, separately for each lane. The fields the page reads or displays:
| field | how the page uses it |
|---|---|
hold_credits | Shown as "reserves N credits ... for the review" (or draft). It is a reservation, not the price: the page adds "you are charged only for what the run actually uses". |
min_credits | Shown as "minimum N". A signed-in balance below it disables the run button; a balance at or above it but below hold_credits gets a warning that the run "may be cut short". |
sponsor_enabled | When true, the page lets a guest run instead of asking them to sign in. |
model, model_alias | The model the run would use and its alias. |
markup_bps | The markup applied to the model cost, in basis points. |
This app declares an input schema with task and facts required, so the estimate response also reports input_checked and a warnings array naming missing or unknown fields; warnings are advisory and do not stop a run.
Observed on this app on 2026-09-28: a well-formed body returned input_checked: true and an empty warnings array; an empty object returned warnings for the missing task and facts but still returned a hold; a body wrapped as {"input": {...}} added a warning for the unknown field input; and a body that is not a JSON object (a bare string) was rejected with HTTP 400 validation_error. The estimate reported model gpt-5.6-terra, model_alias gpt-terra and markup_bps 1000.
The page shows any warnings next to the price as "input warnings". Treat one as a bug
in your body. The snippets below read review.json from the
worked examples; use draft.json for the other lane.
# The body must be an object with string task and facts.
jq -e '(.task == "review" or .task == "draft") and (.facts | type == "string")' review.json > /dev/null
api POST /estimate review.json \
| jq '.data | {hold_credits, min_credits, sponsor_enabled, model, warnings}'
with open("review.json", encoding="utf-8") as f:
body = f.read()
inp = json.loads(body)
if not (isinstance(inp, dict) and inp.get("task") in ("review", "draft") and isinstance(inp.get("facts"), str)):
raise ValueError("body must be an object with task review|draft and facts as a JSON string")
est = call("POST", "/estimate", body)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsor", est.get("sponsor_enabled"), "warnings", est.get("warnings"))
import { readFileSync } from "node:fs";
const body = readFileSync("review.json", "utf8");
const inp = JSON.parse(body);
if (!inp || typeof inp !== "object" || Array.isArray(inp) ||
!["review", "draft"].includes(inp.task) || typeof inp.facts !== "string") {
throw new Error("body must be an object with task review|draft and facts as a JSON string");
}
const est = await call("POST", "/estimate", body);
console.log("hold", est.hold_credits, "min", est.min_credits,
"sponsor", est.sponsor_enabled, "warnings", est.warnings);
type Estimate struct {
HoldCredits float64 `json:"hold_credits"`
MinCredits float64 `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Model string `json:"model"`
Warnings []string `json:"warnings"`
}
func loadBody(path string) ([]byte, error) {
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var inp struct {
Task string `json:"task"`
Facts any `json:"facts"`
}
if err := json.Unmarshal(body, &inp); err != nil {
return nil, err
}
if _, ok := inp.Facts.(string); !ok || (inp.Task != "review" && inp.Task != "draft") {
return nil, fmt.Errorf("body must have task review|draft and facts as a JSON string")
}
return body, nil
}
func estimate(body []byte) (*Estimate, error) {
data, err := call("POST", "/estimate", body, nil)
if err != nil {
return nil, err
}
var est Estimate
return &est, json.Unmarshal(data, &est)
}
// add imports: java.nio.file.Files, java.nio.file.Path
String body = Files.readString(Path.of("review.json"));
JsonNode inp = JSON.readTree(body);
String task = inp.path("task").asText("");
if (!inp.isObject() || !(task.equals("review") || task.equals("draft")) || !inp.path("facts").isTextual())
throw new IllegalArgumentException("body must have task review|draft and facts as a JSON string");
JsonNode est = call("POST", "/estimate", body, null);
System.out.println("hold " + est.path("hold_credits").asLong() + " min " + est.path("min_credits").asLong()
+ " sponsor " + est.path("sponsor_enabled").asBoolean(false) + " warnings " + est.path("warnings"));
body = File.read("review.json", encoding: "UTF-8")
inp = JSON.parse(body)
unless inp.is_a?(Hash) && %w[review draft].include?(inp["task"]) && inp["facts"].is_a?(String)
raise ArgumentError, "body must have task review|draft and facts as a JSON string"
end
est = call("POST", "/estimate", body)
puts "hold #{est['hold_credits']} min #{est['min_credits']} sponsor #{est['sponsor_enabled']} warnings #{est['warnings']}"
<?php
$body = file_get_contents("review.json");
$inp = json_decode($body, true);
if (!is_array($inp) || !in_array($inp["task"] ?? null, ["review", "draft"], true) || !is_string($inp["facts"] ?? null)) {
throw new InvalidArgumentException("body must have task review|draft and facts as a JSON string");
}
$est = call("POST", "/estimate", $body);
echo "hold ", $est["hold_credits"] ?? "", " min ", $est["min_credits"] ?? "",
" sponsor ", !empty($est["sponsor_enabled"]) ? "yes" : "no",
" warnings ", json_encode($est["warnings"] ?? []), PHP_EOL;
var body = File.ReadAllText("review.json");
var inp = JsonNode.Parse(body) as JsonObject;
var task = inp?["task"]?.GetValueKind() == JsonValueKind.String ? (string?)inp["task"] : null;
if (inp is null || task is not ("review" or "draft") || inp["facts"]?.GetValueKind() != JsonValueKind.String)
throw new ArgumentException("body must have task review|draft and facts as a JSON string");
var est = await Api.Call(HttpMethod.Post, "/estimate", body);
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} " +
$"sponsor {est?["sponsor_enabled"]} warnings {est?["warnings"]?.ToJsonString()}");
5. Run it, then poll the job
POST /run with the same body starts the run and returns {"job_id": "..."}.
Then poll GET /jobs/{job_id} until status is succeeded or
failed; the SDK polls once a second and gives up after 180 seconds. The finished job
carries the model's reply as text in output.output, plus charged_credits,
truncated and, on failure, error. When truncated is true the
page tells the marketer the reply "was cut short by the available balance" and shows only the
sections that arrived.
Send an Idempotency-Key header on every run; it is the header the SDK sets on
/run and /run-stream. The page derives it from the lane and the input:
sms-desk:<task>:<hash of JSON.stringify(input)>:a<attempt>, where the hash
is the page's own 32-bit inputHash. For the two worked examples that gives
sms-desk:review:fc1b81d4-b0b:a1 and sms-desk:draft:d391f4b6-292:a1. The
page's stated purpose is that a retry of the same input reuses the same key, so "a network blip
never double-bills". Its one automatic reformat retry keeps the hash of the original input (without
retry_note) and moves to :a2, and the page announces it as "one extra run".
The JavaScript tab below uses the page's exact hash; the other tabs use the first 16 hex digits of a
SHA-256 of the body bytes, which is just as deterministic. Change the attempt suffix only when you
mean a new run.
TASK=$(jq -r '.task' review.json)
KEY="sms-desk:$TASK:$(shasum -a 256 review.json | cut -c1-16):a1"
JOB=$(api POST /run review.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')
echo "job $JOB"
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 > reply.txt # the model's reply: one JSON object as text
import hashlib, time, urllib.parse
def idem_key(body, attempt=1):
task = json.loads(body)["task"]
return f"sms-desk:{task}:{hashlib.sha256(body.encode('utf-8')).hexdigest()[:16]}:a{attempt}"
def wait_job(job_id, interval=1.0, timeout=180.0):
deadline = time.monotonic() + timeout
while True:
job = call("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
return job
if time.monotonic() > deadline:
raise TimeoutError(f"job {job_id} timed out")
time.sleep(interval)
def reply_text(job):
out = job.get("output") or {}
return out.get("output") if isinstance(out, dict) else None
def run(body, attempt=1):
started = call("POST", "/run", body, {"Idempotency-Key": idem_key(body, attempt)})
return wait_job(started["job_id"])
job = run(body)
print(job["status"], "charged:", job.get("charged_credits"), "truncated:", job.get("truncated") is True)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = reply_text(job)
// The page's own key, from app.js: djb2 over JSON.stringify(input), plus the length, in hex.
function inputHash(input) {
const s = JSON.stringify(input);
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(16) + "-" + s.length.toString(16);
}
function idemKey(body, attempt = 1) {
const input = JSON.parse(body);
delete input.retry_note; // the page hashes the input before it adds a retry_note
return `sms-desk:${input.task}:${inputHash(input)}:a${attempt}`;
}
async function waitJob(jobId, { intervalMs = 1000, timeoutMs = 180000 } = {}) {
const start = Date.now();
for (;;) {
const job = await call("GET", "/jobs/" + encodeURIComponent(jobId));
if (job.status === "succeeded" || job.status === "failed") return job;
if (Date.now() - start > timeoutMs) throw new Error(`job ${jobId} timed out`);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
const replyText = (job) => (job && job.output && job.output.output) || null;
async function run(body, attempt = 1) {
const { job_id } = await call("POST", "/run", body, { "Idempotency-Key": idemKey(body, attempt) });
return waitJob(job_id);
}
const job = await run(body);
console.log(job.status, "charged:", job.charged_credits, "truncated:", job.truncated === true);
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = replyText(job);
// add imports: "crypto/sha256", "encoding/hex", "net/url", "time"
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
ChargedCredits *float64 `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output json.RawMessage `json:"output"`
Error json.RawMessage `json:"error"`
}
// ReplyText returns output.output, the model's reply as text.
func (j *Job) ReplyText() string {
var wrapped struct {
Output string `json:"output"`
}
_ = json.Unmarshal(j.Output, &wrapped)
return wrapped.Output
}
func idemKey(body []byte, attempt int) string {
var inp struct {
Task string `json:"task"`
}
_ = json.Unmarshal(body, &inp)
sum := sha256.Sum256(body)
return fmt.Sprintf("sms-desk:%s:%s:a%d", inp.Task, hex.EncodeToString(sum[:])[:16], attempt)
}
func waitJob(jobID string) (*Job, error) {
deadline := time.Now().Add(180 * time.Second)
for {
data, err := call("GET", "/jobs/"+url.PathEscape(jobID), nil, nil)
if err != nil {
return nil, err
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
return nil, err
}
if job.Status == "succeeded" || job.Status == "failed" {
return &job, nil
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("job %s timed out", jobID)
}
time.Sleep(time.Second)
}
}
func run(body []byte, attempt int) (*Job, error) {
data, err := call("POST", "/run", body, map[string]string{"Idempotency-Key": idemKey(body, attempt)})
if err != nil {
return nil, err
}
var started struct {
JobID string `json:"job_id"`
}
if err := json.Unmarshal(data, &started); err != nil {
return nil, err
}
return waitJob(started.JobID)
}
// add imports: java.net.URLEncoder, java.nio.charset.StandardCharsets,
// java.security.MessageDigest, java.util.HexFormat
static String idemKey(String body, int attempt) throws Exception {
String task = JSON.readTree(body).path("task").asText();
byte[] d = MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8));
return "sms-desk:" + task + ":" + HexFormat.of().formatHex(d).substring(0, 16) + ":a" + attempt;
}
static JsonNode waitJob(String jobId) throws Exception {
long deadline = System.currentTimeMillis() + 180_000;
while (true) {
JsonNode job = call("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null, null);
String st = job.path("status").asText();
if (st.equals("succeeded") || st.equals("failed")) return job;
if (System.currentTimeMillis() > deadline) throw new Exception("job " + jobId + " timed out");
Thread.sleep(1000);
}
}
static String replyText(JsonNode job) {
return job.path("output").path("output").asText(null);
}
static JsonNode run(String body, int attempt) throws Exception {
JsonNode started = call("POST", "/run", body, Map.of("Idempotency-Key", idemKey(body, attempt)));
return waitJob(started.path("job_id").asText());
}
JsonNode job = run(body, 1);
System.out.println(job.path("status").asText() + " charged: " + job.path("charged_credits").asText()
+ " truncated: " + job.path("truncated").asBoolean(false));
String text = replyText(job);
require "digest"
require "erb"
def idem_key(body, attempt = 1)
"sms-desk:#{JSON.parse(body)['task']}:#{Digest::SHA256.hexdigest(body)[0, 16]}:a#{attempt}"
end
def wait_job(job_id, interval: 1, timeout: 180)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
job = call("GET", "/jobs/#{ERB::Util.url_encode(job_id)}")
return job if %w[succeeded failed].include?(job["status"])
raise "job #{job_id} timed out" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep interval
end
end
def reply_text(job)
out = job["output"]
out.is_a?(Hash) ? out["output"] : nil
end
def run(body, attempt = 1)
started = call("POST", "/run", body, "Idempotency-Key" => idem_key(body, attempt))
wait_job(started["job_id"])
end
job = run(body)
puts "#{job['status']} charged: #{job['charged_credits']} truncated: #{job['truncated'] == true}"
raise "run failed: #{job['error']}" if job["status"] == "failed"
text = reply_text(job)
<?php
function idem_key(string $body, int $attempt = 1): string {
$task = json_decode($body, true)["task"];
return "sms-desk:$task:" . substr(hash("sha256", $body), 0, 16) . ":a$attempt";
}
function wait_job(string $jobId, int $timeout = 180): array {
$deadline = time() + $timeout;
while (true) {
$job = call("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) return $job;
if (time() > $deadline) throw new RuntimeException("job $jobId timed out");
sleep(1);
}
}
function reply_text(array $job): ?string {
$out = $job["output"] ?? null;
return is_array($out) ? ($out["output"] ?? null) : null;
}
function run(string $body, int $attempt = 1): array {
$started = call("POST", "/run", $body, ["Idempotency-Key" => idem_key($body, $attempt)]);
return wait_job($started["job_id"]);
}
$job = run($body);
echo $job["status"], " charged: ", $job["charged_credits"] ?? "", " truncated: ",
!empty($job["truncated"]) ? "yes" : "no", PHP_EOL;
if ($job["status"] === "failed") throw new RuntimeException(json_encode($job["error"] ?? null));
$text = reply_text($job);
static string IdemKey(string body, int attempt = 1)
{
var task = (string?)JsonNode.Parse(body)?["task"];
var hex = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
return $"sms-desk:{task}:{hex[..16]}:a{attempt}";
}
static async Task<JsonNode> WaitJob(string jobId)
{
var deadline = DateTime.UtcNow.AddSeconds(180);
while (true)
{
var job = await Api.Call(HttpMethod.Get, "/jobs/" + Uri.EscapeDataString(jobId))
?? throw new InvalidOperationException("empty job");
if ((string?)job["status"] is "succeeded" or "failed") return job;
if (DateTime.UtcNow > deadline) throw new TimeoutException($"job {jobId} timed out");
await Task.Delay(1000);
}
}
static string? ReplyText(JsonNode job) => (string?)job["output"]?["output"];
static async Task<JsonNode> Run(string body, int attempt = 1)
{
var started = await Api.Call(HttpMethod.Post, "/run", body,
new Dictionary<string, string> { ["Idempotency-Key"] = IdemKey(body, attempt) });
return await WaitJob((string)started!["job_id"]!);
}
var job = await Run(body);
Console.WriteLine($"{job["status"]} charged: {job["charged_credits"]} truncated: {job["truncated"]}");
var text = ReplyText(job);
6. Or stream it
POST /run-stream takes the same body and the same Idempotency-Key header.
When the response Content-Type is text/event-stream, it is a stream of
frames separated by a blank line; each frame has an event: line and one or more
data: lines whose JSON the SDK joins and parses (frames with no data, or data that
does not parse, are skipped). With any other Content-Type the SDK reads the response as
a plain {data} / {error} envelope, raising on a non-2xx status and otherwise
returning data; its own comment says this is the fallback on idempotent replays. The
events the SDK handles:
delta:{"text": "..."}, the next piece of the reply.job: the job record. The page uses it only to move its progress display on.done: the final payload{job_id, status, charged_credits, output}, with the reply inoutput.output.pending: handled exactly likedone: its data becomes the result.error:{message, code, job_id}; the SDK throws with those three.
Any other event name is ignored. Do not assemble the reply from deltas: the page's own comment is
that "browsers receive ticks, not deltas, from /run-stream", so it reads the reply from
done.output.output and falls back to the deltas only when that is empty. The snippets
below do the same, and if the final payload carries no reply but has a job_id they
fetch the job as in step 5.
TASK=$(jq -r '.task' review.json)
KEY="sms-desk:$TASK:$(shasum -a 256 review.json | cut -c1-16):a1"
# -N turns off buffering so frames print as they arrive; tee keeps a copy.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data-binary @review.json | tee stream.txt
# The final payload is the data line after "event: done" (or "event: pending").
awk '/^event: *(done|pending)/ { getline; sub(/^data: ?/, ""); print }' stream.txt \
| tail -n 1 | jq '{job_id, status, charged_credits, reply: .output.output}'
def run_stream(body, attempt=1, on_text=lambda t: None):
req = urllib.request.Request(BASE + "/run-stream", data=body.encode("utf-8"), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", idem_key(body, attempt))
try:
r = 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) from None
result, deltas = None, []
with r:
if "text/event-stream" not in r.headers.get("Content-Type", ""):
return json.load(r).get("data"), "" # a plain envelope
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 == "":
try:
payload = json.loads(data) if data else None
except ValueError:
payload = None
if payload is not None:
if event == "delta":
deltas.append(payload.get("text", ""))
on_text(payload.get("text", ""))
elif event in ("done", "pending"):
result = payload
elif event == "error":
raise ApiError(None, payload.get("code"), payload.get("message"),
{"job_id": payload.get("job_id")})
event, data = "message", ""
if result and not reply_text(result) and result.get("job_id"):
result = wait_job(result["job_id"])
return result, "".join(deltas)
done, streamed = run_stream(body)
text = reply_text(done) or streamed
async function runStream(body, attempt = 1, onText = () => {}) {
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + token,
"Idempotency-Key": idemKey(body, attempt),
},
body,
});
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error((json.error && json.error.message) || res.statusText),
{ status: res.status, code: json.error && json.error.code });
return { done: json.data, streamed: "" }; // a plain envelope
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", result = null, streamed = "";
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 frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", data = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
let payload;
try { payload = JSON.parse(data); } catch { continue; }
if (event === "delta") { streamed += payload.text || ""; onText(payload.text || ""); }
else if (event === "done" || event === "pending") result = payload;
else if (event === "error") throw Object.assign(new Error(payload.message || "job failed"),
{ code: payload.code, job_id: payload.job_id });
}
}
if (result && !replyText(result) && result.job_id) result = await waitJob(result.job_id);
return { done: result, streamed };
}
const { done, streamed } = await runStream(body);
const text = replyText(done) || streamed;
// add imports: "bufio", "strings"
// runStream returns the final payload (same shape as a Job) and any streamed text.
func runStream(body []byte, attempt int) (*Job, string, error) {
req, err := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(body, attempt))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer res.Body.Close()
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct {
Data *Job `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return nil, "", e
}
return env.Data, "", nil // a plain envelope
}
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
event, data := "message", ""
var streamed strings.Builder
var result *Job
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "":
if data != "" {
switch event {
case "delta":
var d struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(data), &d) == nil {
streamed.WriteString(d.Text)
}
case "done", "pending":
var j Job
if json.Unmarshal([]byte(data), &j) == nil {
result = &j
}
case "error":
var e struct {
Code string `json:"code"`
Message string `json:"message"`
JobID string `json:"job_id"`
}
_ = json.Unmarshal([]byte(data), &e)
return nil, "", fmt.Errorf("%s: %s (job %s)", e.Code, e.Message, e.JobID)
}
}
event, data = "message", ""
}
}
if err := sc.Err(); err != nil {
return nil, "", err
}
if result != nil && result.ReplyText() == "" && result.JobID != "" {
if polled, err := waitJob(result.JobID); err == nil {
result = polled
}
}
return result, streamed.String(), nil
}
// add imports: java.util.Iterator, java.util.stream.Collectors, java.util.stream.Stream
static JsonNode runStream(String body, int attempt) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", idemKey(body, attempt))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<Stream<String>> res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String ctype = res.headers().firstValue("content-type").orElse("");
if (!ctype.contains("text/event-stream")) { // a plain envelope
JsonNode env = JSON.readTree(res.body().collect(Collectors.joining("\n")));
if (res.statusCode() < 200 || res.statusCode() > 299) {
throw new ApiException(res.statusCode(), env.path("error").path("code").asText(null),
env.path("error").path("message").asText("HTTP " + res.statusCode()));
}
return env.path("data");
}
String event = "message";
StringBuilder data = new StringBuilder();
JsonNode result = null;
Iterator<String> lines = res.body().iterator();
while (lines.hasNext()) {
String line = lines.next();
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
else if (line.isEmpty()) {
if (data.length() > 0) {
JsonNode p;
try { p = JSON.readTree(data.toString()); } catch (Exception e) { p = null; }
if (p != null) {
switch (event) {
case "delta" -> System.out.print(p.path("text").asText(""));
case "done", "pending" -> result = p;
case "error" -> throw new Exception(p.path("code").asText() + ": " + p.path("message").asText()
+ " (job " + p.path("job_id").asText() + ")");
default -> { }
}
}
}
event = "message";
data.setLength(0);
}
}
if (result != null && replyText(result) == null && result.hasNonNull("job_id"))
result = waitJob(result.path("job_id").asText());
return result;
}
JsonNode done = runStream(body, 1);
String text = replyText(done);
def run_stream(body, attempt = 1)
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}"
req["Idempotency-Key"] = idem_key(body, attempt)
req.body = body
result = nil
streamed = +""
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")
env = (JSON.parse(res.read_body) rescue {})
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message) unless res.is_a?(Net::HTTPSuccess)
return [env["data"], ""] # a plain envelope
end
buffer = +""
res.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
frame = buffer.slice!(0, idx + 2)
event = "message"
data = +""
frame.each_line(chomp: true) do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then streamed << payload["text"].to_s
when "done", "pending" then result = payload
when "error" then raise "#{payload['code']}: #{payload['message']} (job #{payload['job_id']})"
end
end
end
end
end
result = wait_job(result["job_id"]) if result && reply_text(result).nil? && result["job_id"]
[result, streamed]
end
done, streamed = run_stream(body)
text = reply_text(done) || streamed
<?php
function run_stream(string $body, int $attempt = 1): array {
global $TOKEN;
$buffer = ""; $raw = ""; $ctype = ""; $result = null; $failure = null; $streamed = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: " . idem_key($body, $attempt),
],
CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$ctype) {
if (stripos($h, "content-type:") === 0) $ctype = trim(substr($h, 13));
return strlen($h);
},
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buffer, &$raw, &$ctype, &$result, &$failure, &$streamed) {
if (stripos($ctype, "text/event-stream") === false) { $raw .= $chunk; return strlen($chunk); }
$buffer .= $chunk;
while (($idx = strpos($buffer, "\n\n")) !== false) {
$frame = substr($buffer, 0, $idx);
$buffer = substr($buffer, $idx + 2);
$event = "message"; $data = "";
foreach (explode("\n", $frame) as $line) {
if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
elseif (str_starts_with($line, "data:")) $data .= trim(substr($line, 5));
}
$p = $data === "" ? null : json_decode($data, true);
if ($p === null) continue;
if ($event === "delta") $streamed .= $p["text"] ?? "";
elseif ($event === "done" || $event === "pending") $result = $p;
elseif ($event === "error") $failure = $p;
}
return strlen($chunk);
},
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw !== "") { // a plain envelope
$env = json_decode($raw, true) ?: [];
if ($status < 200 || $status > 299) {
throw new ApiError($status, $env["error"]["code"] ?? null, $env["error"]["message"] ?? "HTTP $status");
}
return [$env["data"] ?? null, ""];
}
if ($failure) throw new RuntimeException(($failure["code"] ?? "") . ": " . ($failure["message"] ?? "job failed"));
if ($result && reply_text($result) === null && !empty($result["job_id"])) $result = wait_job($result["job_id"]);
return [$result, $streamed];
}
[$done, $streamed] = run_stream($body);
$text = reply_text($done) ?? $streamed;
static async Task<(JsonNode? Done, string Streamed)> RunStream(string body, int attempt = 1)
{
using var req = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Api.Token);
req.Headers.TryAddWithoutValidation("Idempotency-Key", IdemKey(body, attempt));
using var res = await Api.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
var env = JsonNode.Parse(await res.Content.ReadAsStringAsync()); // a plain envelope
if (!res.IsSuccessStatusCode)
throw new ApiException((int)res.StatusCode, (string?)env?["error"]?["code"], (string?)env?["error"]?["message"]);
return (env?["data"], "");
}
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string evt = "message", data = "";
var streamed = new StringBuilder();
JsonNode? result = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:")) data += line[5..].Trim();
else if (line.Length == 0)
{
JsonNode? p = null;
if (data.Length > 0) { try { p = JsonNode.Parse(data); } catch (JsonException) { } }
if (p is not null)
{
switch (evt)
{
case "delta": streamed.Append((string?)p["text"]); break;
case "done" or "pending": result = p; break;
case "error": throw new Exception($"{p["code"]}: {p["message"]} (job {p["job_id"]})");
}
}
evt = "message"; data = "";
}
}
if (result is not null && ReplyText(result) is null && (string?)result["job_id"] is { } id)
result = await WaitJob(id);
return (result, streamed.ToString());
}
var (done, streamed) = await RunStream(body);
var text = (done is null ? null : ReplyText(done)) ?? streamed;
Reading the reply
text is the model's reply, which the system prompt requires to be exactly one JSON
object. Parse it the way the page does (Recon.parseResult): trim, drop a leading and
trailing code fence if present, and parse from the first { to the last
}. Then hold it to the contract for its lane; a
lane field that names the other lane is flagged by the page as a mismatch.
When the reply cannot be parsed, the page retries exactly once, with the same body plus a
retry_note that says the previous reply "was not the single valid JSON object the
instructions require" and asks for "ONLY the JSON object for task" of the lane, with every array
present, and with the attempt suffix of the key moved on. If the retry also fails it shows the raw
reply. When a run ends early and the text so far contains a {, the page closes the
JSON prefix with Recon.closeJson and shows whichever sections arrived.
Then remember what the page adds: none of the re-measuring, re-linting or
verdict checks happen on the server. If a rewrite or a drafted message is going to a real SMS
platform, run it back through smskit.js or the page's free check first.