Rebuttal Desk API
Everything the page does with the model goes through the SkillSafe app API at
https://api.skillsafe.ai/v1/app-api. Every call carries
Authorization: Bearer <token> and a JSON body. A reply is an envelope:
{"data": ...} on success, {"error": {"code", "message", "details"}}
otherwise, with the HTTP status set. The page's own SDK (sdk.js) reads
exactly these two keys.
Status codes you will meet
| Status | When | What to do |
|---|---|---|
| 200 | The call worked; read data. | - |
| 400 | The body is not a JSON object (observed on /estimate with a bare string). | Send the object from buildInput. |
| 401 | The token is missing, expired or not for this app (observed on /me with a made-up token). | Mint a new guest token or sign in again. |
| 402 | The page treats it as "not enough credits" for a run. | Top up at skillsafe.ai/account/billing/. |
| 429 | Rate limited. | Back off and retry with the same Idempotency-Key. |
1. A tiny client
One helper per language: base URL, bearer token, JSON in, data out.
# Base URL, bearer token, JSON body. Keep the token in a shell variable.
BASE=https://api.skillsafe.ai/v1/app-api
TOKEN="YOUR_TOKEN"
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json", **(headers or {})})
try:
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
except urllib.error.HTTPError as e:
raise RuntimeError(e.code, json.load(e).get("error"))
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error && json.error.message), { status: res.status, error: json.error });
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
func call(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env map[string]any
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return nil, fmt.Errorf("%d %v", res.StatusCode, env["error"])
}
data, _ := env["data"].(map[string]any)
return data, nil
}
import java.net.URI;
import java.net.http.*;
public class Api {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient HTTP = HttpClient.newHttpClient();
// Returns the raw JSON envelope; parse it with the JSON library you already use.
static String call(String method, String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (r.statusCode() >= 400) throw new RuntimeException(r.statusCode() + " " + r.body());
return r.body();
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{res.code} #{env["error"]}" if res.code.to_i >= 400
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $method, string $path, $body = null, array $headers = []) {
$h = ["Authorization: Bearer " . TOKEN, "Content-Type: application/json"];
foreach ($headers as $k => $v) { $h[] = "$k: $v"; }
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); }
$env = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($code >= 400) { throw new Exception($code . " " . json_encode($env["error"] ?? null)); }
return $env["data"];
}
using System.Net.Http;
using System.Text;
using System.Text.Json;
static class Api {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN";
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null, string? idemKey = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (idemKey != null) req.Headers.Add("Idempotency-Key", idemKey);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!res.IsSuccessStatusCode) throw new Exception((int)res.StatusCode + " " + env.GetProperty("error"));
return env.GetProperty("data");
}
}
2. A token
A guest token is minted for this app with POST /guest and a body
naming the app's slug, which is what sdk.js does. It is enough for
/me and /estimate. Running a plan, a draft or an audit is metered and
needs a personal token: sign in on the page, then copy it from
the token page.
# A guest token for this app (no account). Enough for /me and /estimate.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" -d '{"slug":"rebuttal-desk"}'
# -> {"data":{"token":"...", ...}} Use data.token as YOUR_TOKEN.
guest = urllib.request.Request(BASE + "/guest", method="POST",
data=json.dumps({"slug": "rebuttal-desk"}).encode(), headers={"Content-Type": "application/json"})
TOKEN = json.load(urllib.request.urlopen(guest))["data"]["token"]
const g = await fetch(BASE + "/guest", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "rebuttal-desk" }) }).then((r) => r.json());
const guestToken = g.data.token;
// POST /guest without a token; read data.token from the reply.
req, _ := http.NewRequest("POST", base+"/guest", bytes.NewBufferString(`{"slug":"rebuttal-desk"}`))
req.Header.Set("Content-Type", "application/json")
HttpRequest g = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"rebuttal-desk\"}")).build();
String body = HTTP.send(g, HttpResponse.BodyHandlers.ofString()).body(); // data.token
uri = URI(BASE + "/guest")
res = Net::HTTP.post(uri, { slug: "rebuttal-desk" }.to_json, "Content-Type" => "application/json")
guest_token = JSON.parse(res.body)["data"]["token"]
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => '{"slug":"rebuttal-desk"}']);
$guestToken = json_decode(curl_exec($ch), true)["data"]["token"];
var g = await Http.PostAsync(Base + "/guest",
new StringContent("{\"slug\":\"rebuttal-desk\"}", Encoding.UTF8, "application/json"));
var guestToken = JsonDocument.Parse(await g.Content.ReadAsStringAsync()).RootElement
.GetProperty("data").GetProperty("token").GetString();
3. Who am I - GET /me
subject_type is "guest" or "user"; a signed-in token also reports credits. The page compares that balance with the run's reservation before it enables the run button.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
me, err := call("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"], err)
System.out.println(call("GET", "/me", null));
me = call("GET", "/me")
puts me["subject_type"], me["credits"]
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "";
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type"));
4. Build the run input
The input is one JSON object. The model is told to answer exactly the comment ids the
browser numbered, so the reliable way to build it is the page's own free lane,
rebutkit.js, which also runs in Node:
analyze(set) reads the letter, notes, manuscript text and draft, and
buildInput(analysis) returns the body the page sends.
# The run input is built by the page's own free lane. In Node:
curl -sO https://rebuttal-desk.skillsafe.ai/rebutkit.js
node -e '
global.window = {}; require("./rebutkit.js"); const K = window.RebutKit;
const fs = require("fs");
const A = K.analyze({ lane: "plan", letter: fs.readFileSync("letter.txt", "utf8"),
notes: fs.readFileSync("notes.txt", "utf8") });
fs.writeFileSync("input.json", JSON.stringify(K.buildInput(A)));
'
# Build input.json with rebutkit.js in Node (see the cURL tab), then:
inp = json.load(open("input.json"))
assert isinstance(inp, dict) and inp["task"] in ("plan", "draft", "audit")
// Node: the same module the page runs.
globalThis.window = {};
require("./rebutkit.js"); // downloaded from https://rebuttal-desk.skillsafe.ai/rebutkit.js
const K = window.RebutKit;
const A = K.analyze({ lane: "plan", letter: letterText, notes: notesText });
const input = K.buildInput(A); // { task, title, decision, letter, notes, manuscript, facts }
console.log(A.P.items.map((i) => i.id)); // E.1 ... R1.1 ... - the ids the model must answer
// Build input.json with rebutkit.js in Node (see the cURL tab), then load it:
raw, _ := os.ReadFile("input.json")
var input map[string]any
json.Unmarshal(raw, &input)
// Build input.json with rebutkit.js in Node (see the cURL tab), then:
String input = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
# Build input.json with rebutkit.js in Node (see the cURL tab), then:
input = JSON.parse(File.read("input.json"))
// Build input.json with rebutkit.js in Node (see the cURL tab), then:
$input = json_decode(file_get_contents("input.json"), true);
// Build input.json with rebutkit.js in Node (see the cURL tab), then:
var input = JsonDocument.Parse(File.ReadAllText("input.json")).RootElement;
The task field and the other fields
| Field | Sent in | What it is |
|---|---|---|
task | every lane | "plan", "draft" or "audit". |
title | every lane | Your label, or the title the letter quotes. |
decision | every lane | major_revision, minor_revision, revise_and_resubmit, transfer_after_review, appeal_like or unclear: your choice, else the editor's explicit label, else unclear. |
letter | every lane | The letter with each line prefixed 12| ; past 40,000 characters the middle lines are left out and marked. |
notes | every lane | Your notes, one line per id (R1.2: ...); first 12,000 characters. |
manuscript | every lane | Revised passages; 20,000 characters, head and tail kept. |
plan, scope, cover_letter | draft | The handed-over plan text; "all", "E" or a reviewer id such as "R2"; "yes" or "no". |
draft | audit | Your draft reply (text, Markdown or LaTeX); first 30,000 characters. |
facts | every lane | A JSON string: the numbered items, flags, metadata, the ids in scope and, in the audit lane, the browser's coverage of your draft. |
question | when you ask one | Up to 2,000 characters, answered in the summary. |
retry_note | the page's one reformat retry | Tells the model its previous reply was not valid JSON. |
The app declares these fields to the platform, with task, decision,
letter and facts required, so /estimate answers a body
that lacks one of them, or that carries an unknown field, with warnings. A
warning does not stop a run.
One worked input per lane
// plan
{
"task": "plan",
"title": "TMEM219 shedding, SBR revision",
"decision": "major_revision",
"letter": "1| Dear Dr Okafor,\n2| \n3| Your manuscript entitled \"Hypoxia-induced TMEM219 shedding drives stromal remodellin...",
"notes": "E.1: every point answered below; in vivo and statistics points are R1.1, R1.3.\nE.2: data deposited at GEO, acc...",
"manuscript": "",
"facts": "{\"decision\":{\"value\":\"major_revision\",\"source\":\"letter\",\"evidence_line\":3,\"conflict_lines\":[]},\"items\":[{\"id\":\"E.1\",\"reviewer\":\"E\",\"lines\":[5,5],\"section\":\"general\",\"category_hint\":\"statistical\",\"categories\":[\"statistical\",\"evidence\",\"scope\"],\"severity_hint\":\"unclear\",\"refs\":[],\"note_hint\":\"note\",\"repeat_of\":[],\"excerpt\":\"In particular, the referees find the central observation interesting but ask for stronger eviden..."
}
// draft
{
"task": "draft",
"title": "TMEM219 shedding, SBR revision",
"decision": "major_revision",
"letter": "1| Dear Dr Okafor,\n2| \n3| Your manuscript entitled \"Hypoxia-induced TMEM219 shedding drives stromal remodellin...",
"notes": "E.1: every point answered below; in vivo and statistics points are R1.1, R1.3.\nE.2: data deposited at GEO, acc...",
"manuscript": "Statistical analysis\nCells were imaged from three independent cultures per condition. Shedding indices were an...",
"plan": "R1.1 | statistical | major | ACCEPT_ANALYSIS | REPORTED_DONE_UNVERIFIED | ...",
"scope": "all",
"cover_letter": "yes",
"facts": "{\"decision\":{\"value\":\"major_revision\",\"source\":\"letter\",\"evidence_line\":3,\"conflict_lines\":[]},\"items\":[{\"id\":\"E.1\",\"reviewer\":\"E\",\"lines\":[5,5],\"section\":\"general\",\"category_hint\":\"statistical\",\"categories\":[\"statistical\",\"evidence\",\"scope\"],\"severity_hint\":\"unclear\",\"refs\":[],\"note_hint\":\"note\",\"repeat_of\":[],\"excerpt\":\"In particular, the referees find the central observation interesting but ask for stronger eviden..."
}
// audit
{
"task": "audit",
"title": "Salt marsh nitrate, JCB minor revision",
"decision": "minor_revision",
"letter": "1| Dear Professor Lindqvist,\n2| \n3| Thank you for submitting your manuscript \"Tidal pumping controls porewater...",
"notes": "",
"manuscript": "",
"draft": "Response to Referee 1\n\nComment 1: The nitrate flux in Table 2 is given without uncertainty. Please report a co...",
"facts": "{\"decision\":{\"value\":\"minor_revision\",\"source\":\"letter\",\"evidence_line\":3,\"conflict_lines\":[]},\"items\":[{\"id\":\"E.1\",\"reviewer\":\"E\",\"lines\":[5,5],\"section\":\"general\",\"category_hint\":\"general\",\"categories\":[],\"severity_hint\":\"unclear\",\"refs\":[],\"note_hint\":\"none\",\"repeat_of\":[],\"excerpt\":\"Please respond to each comment below. Your response letter will be shared with both reviewers.\"},\"...\"],\"flags\":[{\"id\":\"P1\",\"severit..."
}
5. Price it - POST /estimate
Free: no job, no charge. hold_credits is a
reservation, not the price; after a run the charge is reported as
charged_credits. The three lanes send different bodies, so estimate the one you
will run.
curl -s -X POST "$BASE/estimate" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" --data-binary @input.json
est = call("POST", "/estimate", inp)
print(est["hold_credits"], est["model"], est.get("warnings"))
const est = await call("POST", "/estimate", input);
console.log(est.hold_credits, est.model, est.warnings);
est, err := call("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["warnings"], err)
System.out.println(call("POST", "/estimate", input));
est = call("POST", "/estimate", input)
puts est["hold_credits"], est["warnings"].inspect
$est = call("POST", "/estimate", $input);
echo $est["hold_credits"];
var est = await Api.Call(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits"));
6. Run it - POST /run, then GET /jobs/{id}
Send the same body with an Idempotency-Key header. The page derives the key from
the lane, a hash of the exact input and an attempt number, so a network retry never runs twice
and the same letter in two lanes is two runs. /run returns a job_id;
poll the job until its status is succeeded or failed.
The reply text is output.output: one JSON object, described below.
# Metered: needs a personal (signed-in) token. Same body as /estimate.
KEY="rebuttal-desk:plan:$(shasum input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
--data-binary @input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until succeeded or failed; the reply text is data.output.output
curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN"
import hashlib, time
key = "rebuttal-desk:%s:%s:a1" % (inp["task"], hashlib.sha256(json.dumps(inp, sort_keys=True).encode()).hexdigest()[:16])
job = call("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = call("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
// hashOf: any stable hash of JSON.stringify(input) - the same input must give the same key
const key = `rebuttal-desk:${input.task}:${hashOf(input)}:a1`;
const { job_id } = await call("POST", "/run", input, { "Idempotency-Key": key });
let job;
do { await new Promise((r) => setTimeout(r, 1000)); job = await call("GET", "/jobs/" + job_id); }
while (job.status !== "succeeded" && job.status !== "failed");
const reply = JSON.parse(job.output.output);
job, err := call("POST", "/run", input, map[string]string{"Idempotency-Key": key})
// then GET /jobs/{job_id} every second until status is "succeeded" or "failed";
// the reply text is output.output
// POST /run with header Idempotency-Key, then GET /jobs/{job_id} until
// "status" is "succeeded" or "failed"; the reply text is output.output.
job = call("POST", "/run", input, { "Idempotency-Key" => key })
loop do
j = call("GET", "/jobs/#{job["job_id"]}")
break reply = JSON.parse(j["output"]["output"]) if j["status"] == "succeeded"
raise "run failed" if j["status"] == "failed"
sleep 1
end
$job = call("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(1); $j = call("GET", "/jobs/" . $job["job_id"]); }
while ($j["status"] !== "succeeded" && $j["status"] !== "failed");
$reply = json_decode($j["output"]["output"], true);
var job = await Api.Call(HttpMethod.Post, "/run", input, key);
JsonElement j;
do { await Task.Delay(1000); j = await Api.Call(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString()); }
while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));
7. Or stream it - POST /run-stream
Server-sent events: job, delta ({"text"}),
done (the job, with charged_credits and output) and
error, as the SDK parses them. The page does not rely on delta
frames - it advances its progress card on elapsed time and renders from done.
curl -sN -X POST "$BASE/run-stream" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" --data-binary @input.json
# event: job data: {...}
# event: delta data: {"text":"..."}
# event: done data: {"job_id":..., "status":..., "charged_credits":..., "output":{...}}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(inp).encode(),
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json", "Idempotency-Key": key})
event = None
for line in urllib.request.urlopen(req):
line = line.decode().strip()
if line.startswith("event:"): event = line[6:].strip()
elif line.startswith("data:") and event == "done": done = json.loads(line[5:])
const res = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(input),
headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json", "Idempotency-Key": key } });
// Read res.body as text/event-stream: blank-line separated frames of "event:" and "data:" lines.
// Keep the "done" frame; its output.output is the reply.
// POST /run-stream with the same headers and read the body line by line (bufio.Scanner):
// "event: <name>" then "data: <json>"; keep the data of the "done" event.
// POST /run-stream, read the body with BodyHandlers.ofLines(): "event:" / "data:" pairs;
// the "done" event carries the job and output.output.
# POST /run-stream and read the body in chunks (res.read_body { |c| ... }):
# frames are "event: name" + "data: json"; keep the "done" frame.
// POST /run-stream with CURLOPT_WRITEFUNCTION to receive frames as they arrive;
// the "done" frame's output.output is the reply.
// POST /run-stream with HttpCompletionOption.ResponseHeadersRead and read the stream
// line by line; keep the data of the "done" event.
8. What comes back
Every lane returns the same envelope around its own body:
{
"lane": "plan" | "draft" | "audit",
"decision_type": "major_revision" | "minor_revision" | ... | "unclear",
"readiness": "ready_to_submit" | "draft_with_placeholders" | "needs_author_input" | "blocked",
"headline": "...", "tldr": ["..."],
...lane body...,
"missing_info": [{"id": "R1.2", "question": "..."}],
"prescan_responses": [{"ref": "P1", "verdict": "confirmed" | "dismissed", "note": "..."}]
}
- plan:
strategy{posture, major_risks, ordering, package},tracker(one row per id: concern, category, severity, action, work_status, required_input, expected_output, verification_evidence, blocks_finalization, risk, location, duplicate_of),conflicts,checklist. - draft:
responses(one per id in scope: response, revised_text, proposed_text, location, action, work_status),openingsper reviewer,cover_letter,checklist. The reply does not repeat the reviewer comment; the page copies it from the letter when it assembles one file per reviewer. - audit:
coverage(answered, partial or missing per id),findings(dimension, severity, verbatim evidence from your draft, problem, fix),rewrites.
The page then holds the reply to account in the browser (recon.js): one row per id, the decision copied, readiness no looser than the rows, "verified" only with text copied from your manuscript, and no figure, line, reference, identifier or decimal your inputs never mention. Do the same with the reply you get from the API - the model can be wrong.