← Rebuttal Desk / API
Tokens

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

StatusWhenWhat to do
200The call worked; read data.-
400The body is not a JSON object (observed on /estimate with a bare string).Send the object from buildInput.
401The 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.
402The page treats it as "not enough credits" for a run.Top up at skillsafe.ai/account/billing/.
429Rate 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"

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.

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"

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)));
'

The task field and the other fields

FieldSent inWhat it is
taskevery lane"plan", "draft" or "audit".
titleevery laneYour label, or the title the letter quotes.
decisionevery lanemajor_revision, minor_revision, revise_and_resubmit, transfer_after_review, appeal_like or unclear: your choice, else the editor's explicit label, else unclear.
letterevery laneThe letter with each line prefixed 12| ; past 40,000 characters the middle lines are left out and marked.
notesevery laneYour notes, one line per id (R1.2: ...); first 12,000 characters.
manuscriptevery laneRevised passages; 20,000 characters, head and tail kept.
plan, scope, cover_letterdraftThe handed-over plan text; "all", "E" or a reviewer id such as "R2"; "yes" or "no".
draftauditYour draft reply (text, Markdown or LaTeX); first 30,000 characters.
factsevery laneA JSON string: the numbered items, flags, metadata, the ids in scope and, in the audit lane, the browser's coverage of your draft.
questionwhen you ask oneUp to 2,000 characters, answered in the summary.
retry_notethe page's one reformat retryTells 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

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"

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

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

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.