Nature Review Studio — API

Measure and review manuscripts, from your own tools.

API tokens Open the app

Measure a manuscript, then review it, from your own pipeline

Two lanes, and only one of them costs anything. The free lane is a client-side engine you can vendor into your own tooling: it reads a manuscript (PDF text layer, Word, LaTeX, OpenDocument, RTF, EPUB, HTML, Markdown, plain text), detects the format from its magic bytes, and measures structure, section word counts, the figure and table inventory with caption presence, reference count and format consistency, the citation-to-reference cross-check in both directions, statistical reporting gaps, ethics and data-availability statements, readability and hedging. The metered lane sends the manuscript plus those measured facts and returns one JSON object: a banner carrying one of four revision-level bands, an editor_letter, 2–5 reviewers with major and minor concerns tagged to a 12-axis taxonomy, a consensus block, and a revision tasks table. Then the same engine reconciles the review against the measurements and names every figure, table, section, reference or statistic it cited that the manuscript does not contain. Wire it into a submission checklist, a lab's pre-submission gate, or your own desk-check queue. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Attribution and non-affiliation. This app is a derived work of the open project mumdark/nature-review-studio (https://github.com/mumdark/nature-review-studio) — its 12-axis concern taxonomy, manuscript-fingerprint→reviewer-set mappings, closed strategy and status vocabularies, four revision-level bands and consensus-only output rule. That repository publishes no licence file, so this is a clearly derived work with credit rather than a republication, and none of its corpus is redistributed. The source project does not endorse this app — and the app's name is inherited from it: the word “Nature” in Nature Review Studio refers to that project, not to the journal Nature or its publisher. This app is not affiliated with, endorsed by, or connected to Nature, Nature Portfolio, or Springer Nature; the banner it returns is this tool's own assessment, not any journal's editorial decision, and nothing it produces is a substitute for real peer review.

Basics

Base URL https://api.skillsafe.ai/v1/app-api, app slug nature-review-studio. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Reviews are written by the gpt-terra model alias (currently gpt-5.6-terra) at a publisher markup of 1000 bps — 10%. Credits are in units of 1/10 000 of a US dollar, so 10 000 credits is $1.00.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/reviews/query

Error codes

HTTPcodeWhat it means and what to do
400validation_errorThe body is missing a required field or a field has the wrong type. error.details names it. POST /guest in particular needs slug in the body — an X-App-Slug header is not accepted.
401unauthorizedNo token, a malformed token, or a token that has expired. Mint a new guest token or sign in again.
402payment_requiredThe balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits.
404not_foundUnknown job id, unknown collection, or a record that belongs to another subject. Guest identities are per-token: a new guest token cannot see the previous guest's records.
409conflictAn Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
500internal_errorTransient. Retry with the same Idempotency-Key so the retry cannot bill twice.
The one call that costs money is /run and /run-stream. /guest, /me and /estimate are free, so a client can price a run, check the balance and prove the model binding without spending anything.

Step 1 · Get a token

Two ways in. If you already use the app in a browser, open the token page and press Copy shell export — it hands you the exact export SKILLSAFE_TOKEN="…" line, with no DevTools console involved. For a fully scripted client, POST /guest mints a guest token with no browser at all. Guest tokens can call /me and the free /estimate; a personal token is what bills page runs to your own account.

# Option A — take the token this browser already has: open /tokens.html,
      # press "Copy shell export", and paste the line it gives you.
      export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"
      
      # Option B — mint a guest token with no browser at all. Guest tokens can call
      # /me and the free /estimate; sign in for a personal token to bill page runs
      # to your own account.
      curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
        -H 'Content-Type: application/json' \
        -d '{"slug":"nature-review-studio"}'
      # => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request
      
      BASE = "https://api.skillsafe.ai/v1/app-api"
      SLUG = "nature-review-studio"
      
      def call(path, body=None, token=None, method=None):
          data = json.dumps(body).encode() if body is not None else None
          req = urllib.request.Request(BASE + path, data=data,
                                      method=method or ("POST" if data else "GET"))
          req.add_header("Content-Type", "application/json")
          req.add_header("User-Agent", "nature-review-studio-client/1.0")
          if token:
              req.add_header("Authorization", "Bearer " + token)
          with urllib.request.urlopen(req) as r:
              return json.loads(r.read())["data"]
      
      # Option A: the token from /tokens.html, kept in your environment.
      token = os.environ.get("SKILLSAFE_TOKEN")
      
      # Option B: a fresh guest token, no browser involved.
      if not token:
          token = call("/guest", {"slug": SLUG})["token"]
      
      print(token[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
      const SLUG = "nature-review-studio";
      
      async function call(path, { body, token, method } = {}) {
        const res = await fetch(BASE + path, {
          method: method || (body ? "POST" : "GET"),
          headers: {
            "Content-Type": "application/json",
            ...(token ? { Authorization: "Bearer " + token } : {}),
          },
          body: body ? JSON.stringify(body) : undefined,
        });
        const json = await res.json();
        if (json.error) throw Object.assign(new Error(json.error.message), json.error);
        return json.data;
      }
      
      // Option A: paste the token from /tokens.html (or read it from your own config).
      let token = "YOUR_TOKEN";
      
      // Option B: mint a guest token — good for /me and the free /estimate.
      if (token === "YOUR_TOKEN") token = (await call("/guest", { body: { slug: SLUG } })).token;
      
      console.log(token.slice(0, 12) + "...");
package main
      
      import (
          "bytes"
          "encoding/json"
          "errors"
          "fmt"
          "io"
          "net/http"
          "os"
      )
      
      const base = "https://api.skillsafe.ai/v1/app-api"
      const slug = "nature-review-studio"
      
      type envelope struct {
          Data  json.RawMessage `json:"data"`
          Error *struct {
              Code    string `json:"code"`
              Message string `json:"message"`
          } `json:"error"`
      }
      
      func call(path, token string, body any, out any) error {
          var rdr io.Reader
          method := "GET"
          if body != nil {
              b, _ := json.Marshal(body)
              rdr = bytes.NewReader(b)
              method = "POST"
          }
          req, _ := http.NewRequest(method, base+path, rdr)
          req.Header.Set("Content-Type", "application/json")
          if token != "" {
              req.Header.Set("Authorization", "Bearer "+token)
          }
          res, err := http.DefaultClient.Do(req)
          if err != nil {
              return err
          }
          defer res.Body.Close()
          var env envelope
          if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
              return err
          }
          if env.Error != nil {
              return errors.New(env.Error.Code + ": " + env.Error.Message)
          }
          if out != nil {
              return json.Unmarshal(env.Data, out)
          }
          return nil
      }
      
      func main() {
          token := os.Getenv("SKILLSAFE_TOKEN")
          if token == "" {
              var guest struct{ Token string `json:"token"` }
              if err := call("/guest", "", map[string]string{"slug": slug}, &guest); err != nil {
                  panic(err)
              }
              token = guest.Token
          }
          fmt.Println(token[:12] + "...")
      }
import java.net.URI;
      import java.net.http.*;
      import java.util.Map;
      
      public class CiteReady {
          static final String BASE = "https://api.skillsafe.ai/v1/app-api";
          static final String SLUG = "nature-review-studio";
          static final HttpClient HTTP = HttpClient.newHttpClient();
      
          static String call(String path, String token, String jsonBody) throws Exception {
              HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
                  .header("Content-Type", "application/json");
              if (token != null) b.header("Authorization", "Bearer " + token);
              b = jsonBody == null ? b.GET()
                                   : b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
              HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
              return res.body();   // {"data":...} or {"error":{...}} — parse with your JSON library
          }
      
          public static void main(String[] args) throws Exception {
              String token = System.getenv("SKILLSAFE_TOKEN");
              if (token == null) {
                  // POST /guest returns {"data":{"token":"aut_..."}}
                  System.out.println(call("/guest", null, "{\"slug\":\"" + SLUG + "\"}"));
              } else {
                  System.out.println(token.substring(0, 12) + "...");
              }
          }
      }
require "json"
      require "net/http"
      
      BASE = URI("https://api.skillsafe.ai/v1/app-api")
      SLUG = "nature-review-studio"
      
      def call(path, body: nil, token: nil, method: nil)
        uri = URI(BASE.to_s + path)
        req = (method || (body ? "POST" : "GET")) == "POST" ?
          Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
        req["Content-Type"] = "application/json"
        req["Authorization"] = "Bearer #{token}" if token
        req.body = JSON.generate(body) if body
        res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
        json = JSON.parse(res.body)
        raise "#{json['error']['code']}: #{json['error']['message']}" if json["error"]
        json["data"]
      end
      
      token = ENV["SKILLSAFE_TOKEN"] || call("/guest", body: { slug: SLUG })["token"]
      puts token[0, 12] + "..."
<?php
      const BASE = "https://api.skillsafe.ai/v1/app-api";
      const SLUG = "nature-review-studio";
      
      function call(string $path, ?array $body = null, ?string $token = null): array {
          $headers = ["Content-Type: application/json"];
          if ($token) { $headers[] = "Authorization: Bearer " . $token; }
          $ch = curl_init(BASE . $path);
          curl_setopt_array($ch, [
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_HTTPHEADER => $headers,
          ]);
          if ($body !== null) {
              curl_setopt($ch, CURLOPT_POST, true);
              curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
          }
          $json = json_decode(curl_exec($ch), true);
          curl_close($ch);
          if (isset($json["error"])) {
              throw new RuntimeException($json["error"]["code"] . ": " . $json["error"]["message"]);
          }
          return $json["data"];
      }
      
      $token = getenv("SKILLSAFE_TOKEN") ?: call("/guest", ["slug" => SLUG])["token"];
      echo substr($token, 0, 12) . "...\n";
using System;
      using System.Net.Http;
      using System.Net.Http.Json;
      using System.Text.Json;
      using System.Threading.Tasks;
      
      class CiteReady {
          const string Base = "https://api.skillsafe.ai/v1/app-api";
          const string Slug = "nature-review-studio";
          static readonly HttpClient Http = new HttpClient();
      
          static async Task<JsonElement> Call(string path, object body = null, string token = null) {
              var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
              if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
              if (body != null) req.Content = JsonContent.Create(body);
              var res = await Http.SendAsync(req);
              var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
              if (doc.RootElement.TryGetProperty("error", out var err))
                  throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
              return doc.RootElement.GetProperty("data");
          }
      
          static async Task Main() {
              var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
              if (token == null) {
                  var guest = await Call("/guest", new { slug = Slug });
                  token = guest.GetProperty("token").GetString();
              }
              Console.WriteLine(token.Substring(0, 12) + "...");
          }
      }

Step 2 · Check who you are and what you can spend

GET /me returns subject_type (user or guest), subject_id and credits. Compare credits against /estimate's min_credits before submitting a run — a 402 after submit is a client bug, not a user problem.

curl -s https://api.skillsafe.ai/v1/app-api/me \
        -H "Authorization: Bearer $SKILLSAFE_TOKEN"
      # => {"data":{"subject_type":"user","subject_id":"usr_...","credits":184213}}
      #
      # subject_type is "user" for a personal token and "guest" for a guest one.
      # credits is in credit units: 10 000 credits = $1.00.
me = call("/me", token=token)
      print(me["subject_type"], me["credits"], "credits",
            "= $%.2f" % (me["credits"] / 10000))
const me = await call("/me", { token });
      console.log(me.subject_type, me.credits, "credits =",
        "$" + (me.credits / 10000).toFixed(2));
var me struct {
          SubjectType string `json:"subject_type"`
          SubjectID   string `json:"subject_id"`
          Credits     int64  `json:"credits"`
      }
      if err := call("/me", token, nil, &me); err != nil {
          panic(err)
      }
      fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
// GET /me — {"data":{"subject_type":"user","credits":184213}}
      String me = call("/me", token, null);
      System.out.println(me);
me = call("/me", token: token)
      puts "#{me['subject_type']} #{me['credits']} credits = $#{'%.2f' % (me['credits'] / 10000.0)}"
$me = call("/me", null, $token);
      printf("%s %d credits = $%.2f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
      var credits = me.GetProperty("credits").GetInt64();
      Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F2}");

Step 3 · Price the run — free, and it proves the model binding

POST /estimate takes the same body as /run, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Present hold_credits as reserved, never as the price: the hold covers the full output cap, and the settled charged_credits is usually far lower.

# /estimate is free: no job is created, no credits are held, nothing is charged.
      # Use it to show a price and to prove the model binding before you spend anything.
      curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
        -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
        -H 'Content-Type: application/json' \
        -d @review-input.json
      # => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
      #             "hold_credits":3120,"min_credits":260,"sponsor_enabled":false}}
est = call("/estimate", body=review_input, token=token)
      print("model", est["model"], "alias", est["model_alias"], "markup", est["markup_bps"])
      print("reserved up to $%.4f" % (est["hold_credits"] / 10000))
      if me["credits"] < est["min_credits"]:
          raise SystemExit("balance below the model minimum — top up before running")
const est = await call("/estimate", { body: reviewInput, token });
      console.log(est.model, est.model_alias, est.markup_bps);
      console.log("reserved up to $" + (est.hold_credits / 10000).toFixed(4));
      if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
var est struct {
          Model       string `json:"model"`
          ModelAlias  string `json:"model_alias"`
          MarkupBps   int    `json:"markup_bps"`
          HoldCredits int64  `json:"hold_credits"`
          MinCredits  int64  `json:"min_credits"`
      }
      if err := call("/estimate", token, reviewInput, &est); err != nil {
          panic(err)
      }
      fmt.Printf("%s (%s) markup %d bps, reserve $%.4f\n",
          est.Model, est.ModelAlias, est.MarkupBps, float64(est.HoldCredits)/10000)
// POST /estimate with the same body you would send to /run. Free, no job.
      String est = call("/estimate", token, reviewInputJson);
      System.out.println(est);
est = call("/estimate", body: review_input, token: token)
      puts "#{est['model']} (#{est['model_alias']}) markup #{est['markup_bps']} bps"
      puts "reserved up to $#{'%.4f' % (est['hold_credits'] / 10000.0)}"
$est = call("/estimate", $review_input, $token);
      printf("%s (%s) markup %d bps, reserve $%.4f\n",
          $est["model"], $est["model_alias"], $est["markup_bps"], $est["hold_credits"] / 10000);
var est = await Call("/estimate", reviewInput, token);
      Console.WriteLine(est.GetProperty("model").GetString() + " / " +
                        est.GetProperty("model_alias").GetString() + " markup " +
                        est.GetProperty("markup_bps").GetInt32() + " bps");

Step 4 · Run the reviewer panel and poll for it

POST /run returns {"job_id"}; poll GET /jobs/{id} until status is succeeded or failed, then read data.output.output — the review as a JSON string. Always send Idempotency-Key, derived from the input plus an attempt counter: a network blip or a retry after a malformed reply must never bill the same review twice. Reuse the key for a retry of the same input; bump the attempt counter only when the input itself changes.

# Metered. Always send Idempotency-Key: a retry with the same key returns the
      # same job instead of billing twice.
      KEY="nature-review-studio:$(shasum -a 256 review-input.json | cut -c1-16):a1"
      
      JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
        -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
        -H 'Content-Type: application/json' \
        -H "Idempotency-Key: $KEY" \
        -d @review-input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
      
      # Poll until terminal.
      while true; do
        OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
          -H "Authorization: Bearer $SKILLSAFE_TOKEN")
        STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
        [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
        sleep 2
      done
      printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'
      # => the review, as one JSON object (see the output contract below).
import hashlib, time
      
      def idem_key(inp, attempt=1):
          seed = "\u0020".join(str(inp.get(k, "")) for k in
                          ("title", "manuscript"))
          return "nature-review-studio:%s:a%d" % (hashlib.sha256(seed.encode()).hexdigest()[:16], attempt)
      
      def run_review(inp, token, attempt=1):
          data = json.dumps(inp).encode()
          req = urllib.request.Request(BASE + "/run", data=data, method="POST")
          req.add_header("Content-Type", "application/json")
          req.add_header("Authorization", "Bearer " + token)
          req.add_header("Idempotency-Key", idem_key(inp, attempt))
          with urllib.request.urlopen(req) as r:
              job_id = json.loads(r.read())["data"]["job_id"]
          while True:
              job = call("/jobs/" + job_id, token=token)
              if job["status"] in ("succeeded", "failed"):
                  break
              time.sleep(2)
          if job["status"] == "failed":
              raise RuntimeError(job.get("error") or "run failed")
          return json.loads(job["output"]["output"])
      
      page = run_review(review_input, token)
      print(page["command"], "-", len(page["examples"]), "examples")
import { createHash } from "node:crypto";
      
      function idemKey(inp, attempt = 1) {
        const seed = ["title", "manuscript"]
          .map((k) => String(inp[k] ?? "")).join(" ");
        return `nature-review-studio:${createHash("sha256").update(seed).digest("hex").slice(0, 16)}:a${attempt}`;
      }
      
      async function runReview(inp, token, attempt = 1) {
        const res = await fetch(BASE + "/run", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: "Bearer " + token,
            "Idempotency-Key": idemKey(inp, attempt),
          },
          body: JSON.stringify(inp),
        });
        const { data, error } = await res.json();
        if (error) throw new Error(error.message);
        let job;
        do {
          await new Promise((r) => setTimeout(r, 2000));
          job = await call("/jobs/" + data.job_id, { token });
        } while (job.status !== "succeeded" && job.status !== "failed");
        if (job.status === "failed") throw new Error(job.error || "run failed");
        return JSON.parse(job.output.output);
      }
      
      const page = await runReview(reviewInput, token);
      console.log(page.command, "-", page.examples.length + " examples");
import (
          "crypto/sha256"
          "encoding/hex"
          "strings"
          "time"
      )
      
      func idemKey(inp map[string]any, attempt int) string {
          parts := []string{}
          for _, k := range []string{"title", "manuscript"} {
              parts = append(parts, fmt.Sprint(inp[k]))
          }
          sum := sha256.Sum256([]byte(strings.Join(parts, " ")))
          return fmt.Sprintf("nature-review-studio:%s:a%d", hex.EncodeToString(sum[:])[:16], attempt)
      }
      
      // POST /run with the Idempotency-Key header, then poll GET /jobs/{id} every two
      // seconds until status is "succeeded" or "failed". job.Output.Output holds the
      // review as a JSON string; unmarshal it into your own struct.
      func runReview(inp map[string]any, token string) (string, error) {
          b, _ := json.Marshal(inp)
          req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
          req.Header.Set("Content-Type", "application/json")
          req.Header.Set("Authorization", "Bearer "+token)
          req.Header.Set("Idempotency-Key", idemKey(inp, 1))
          res, err := http.DefaultClient.Do(req)
          if err != nil {
              return "", err
          }
          defer res.Body.Close()
          var env envelope
          json.NewDecoder(res.Body).Decode(&env)
          var started struct{ JobID string `json:"job_id"` }
          json.Unmarshal(env.Data, &started)
          for {
              var job struct {
                  Status string `json:"status"`
                  Output struct{ Output string `json:"output"` } `json:"output"`
              }
              if err := call("/jobs/"+started.JobID, token, nil, &job); err != nil {
                  return "", err
              }
              if job.Status == "succeeded" {
                  return job.Output.Output, nil
              }
              if job.Status == "failed" {
                  return "", errors.New("run failed")
              }
              time.Sleep(2 * time.Second)
          }
      }
// POST /run must carry Idempotency-Key, derived from the input plus an attempt
      // counter, so a network retry cannot bill the review twice.
      String key = "nature-review-studio:" + sha256Hex(title + manuscript).substring(0, 16) + ":a1";
      
      HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
          .header("Content-Type", "application/json")
          .header("Authorization", "Bearer " + token)
          .header("Idempotency-Key", key)
          .POST(HttpRequest.BodyPublishers.ofString(reviewInputJson))
          .build();
      String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
      // started => {"data":{"job_id":"job_..."}}
      // then poll GET /jobs/{job_id} until status is succeeded or failed, and read
      // data.output.output — the review JSON as a string.
require "digest"
      
      def idem_key(inp, attempt = 1)
        seed = %w[title manuscript].map { |k| inp[k].to_s }.join(" ")
        "nature-review-studio:#{Digest::SHA256.hexdigest(seed)[0, 16]}:a#{attempt}"
      end
      
      def run_review(inp, token)
        uri = URI(BASE.to_s + "/run")
        req = Net::HTTP::Post.new(uri)
        req["Content-Type"] = "application/json"
        req["Authorization"] = "Bearer #{token}"
        req["Idempotency-Key"] = idem_key(inp)
        req.body = JSON.generate(inp)
        res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
        job_id = JSON.parse(res.body)["data"]["job_id"]
        loop do
          job = call("/jobs/#{job_id}", token: token)
          return JSON.parse(job["output"]["output"]) if job["status"] == "succeeded"
          raise "run failed" if job["status"] == "failed"
          sleep 2
        end
      end
      
      page = run_review(review_input, token)
      puts "#{page['command']} - #{page['examples'].length} examples"
function idem_key(array $inp, int $attempt = 1): string {
          $seed = implode(" ", array_map(fn($k) => (string)($inp[$k] ?? ""),
              ["title", "manuscript"]));
          return "nature-review-studio:" . substr(hash("sha256", $seed), 0, 16) . ":a" . $attempt;
      }
      
      function run_review(array $inp, string $token): array {
          $ch = curl_init(BASE . "/run");
          curl_setopt_array($ch, [
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_POST => true,
              CURLOPT_POSTFIELDS => json_encode($inp),
              CURLOPT_HTTPHEADER => [
                  "Content-Type: application/json",
                  "Authorization: Bearer " . $token,
                  "Idempotency-Key: " . idem_key($inp),
              ],
          ]);
          $job_id = json_decode(curl_exec($ch), true)["data"]["job_id"];
          curl_close($ch);
          while (true) {
              $job = call("/jobs/" . $job_id, null, $token);
              if ($job["status"] === "succeeded") { return json_decode($job["output"]["output"], true); }
              if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
              sleep(2);
          }
      }
      
      $page = run_review($review_input, $token);
      echo $page["command"] . " - " . count($page["examples"]) . " examples" . "\n";
using System.Security.Cryptography;
      using System.Text;
      
      static string IdemKey(Dictionary<string, object> inp, int attempt = 1) {
          var seed = string.Join(" ", new[] { "title", "manuscript" }
              .Select(k => inp.TryGetValue(k, out var v) ? v?.ToString() ?? "" : ""));
          var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(seed))).ToLowerInvariant();
          return $"nature-review-studio:{hash[..16]}:a{attempt}";
      }
      
      var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
          Content = JsonContent.Create(reviewInput)
      };
      req.Headers.Add("Authorization", "Bearer " + token);
      req.Headers.Add("Idempotency-Key", IdemKey(reviewInput));
      var started = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
      var jobId = started.RootElement.GetProperty("data").GetProperty("job_id").GetString();
      
      // Poll GET /jobs/{jobId} every two seconds; on "succeeded", data.output.output is
      // the review as a JSON string.

Step 5 · Or stream it

POST /run-stream is the same call over server-sent events, which is what the web app uses so it can show progress. The frame name arrives on the event: line — job, delta, done — and is not a type field inside the payload. Concatenate every delta payload's text to rebuild the JSON, and read charged_credits and truncated from the done frame. If truncated is true the output cap was reduced to fit the balance: render what parsed and tell the user, rather than presenting a clipped plan as complete.

# Server-sent events. Frame names arrive on the `event:` line, not as a field in
      # the payload — `delta` carries text chunks, `job` the job id, `done` the
      # settlement (charged_credits, truncated).
      curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
        -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
        -H 'Content-Type: application/json' \
        -H "Idempotency-Key: $KEY" \
        -d @review-input.json
      # event: job
      # data: {"job_id":"job_..."}
      # event: delta
      # data: {"text":"{\"command\":\"shipctl\", \"platf"}
      # ...
      # event: done
      # data: {"status":"succeeded","charged_credits":812,"truncated":false}
def run_stream(inp, token, attempt=1, on_delta=None):
          data = json.dumps(inp).encode()
          req = urllib.request.Request(BASE + "/run-stream", data=data, method="POST")
          req.add_header("Content-Type", "application/json")
          req.add_header("Authorization", "Bearer " + token)
          req.add_header("Idempotency-Key", idem_key(inp, attempt))
          raw, event = "", None
          with urllib.request.urlopen(req) as r:
              for line in r:
                  line = line.decode().rstrip("\n")
                  if line.startswith("event:"):
                      event = line[6:].strip()
                  elif line.startswith("data:"):
                      payload = json.loads(line[5:].strip() or "{}")
                      if event == "delta":
                          raw += payload.get("text", "")
                          if on_delta:
                              on_delta(payload.get("text", ""))
                      elif event == "done":
                          return json.loads(raw), payload
          raise RuntimeError("stream ended without a done frame")
      
      page, settle = run_stream(review_input, token)
      print(page["command"], "charged", settle["charged_credits"])
async function runStream(inp, token, onDelta, attempt = 1) {
        const res = await fetch(BASE + "/run-stream", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: "Bearer " + token,
            "Idempotency-Key": idemKey(inp, attempt),
          },
          body: JSON.stringify(inp),
        });
        const reader = res.body.getReader();
        const dec = new TextDecoder();
        let buf = "", raw = "", event = null;
        for (;;) {
          const { value, done } = await reader.read();
          if (done) break;
          buf += dec.decode(value, { stream: true });
          const lines = buf.split("\n");
          buf = lines.pop();
          for (const line of lines) {
            if (line.startsWith("event:")) event = line.slice(6).trim();
            else if (line.startsWith("data:")) {
              const payload = JSON.parse(line.slice(5).trim() || "{}");
              if (event === "delta") { raw += payload.text || ""; onDelta?.(payload.text || ""); }
              else if (event === "done") return { page: JSON.parse(raw), settle: payload };
            }
          }
        }
        throw new Error("stream ended without a done frame");
      }
      
      const { page, settle } = await runStream(reviewInput, token, (t) => process.stdout.write(t));
      console.log("\n", page.command, "charged", settle.charged_credits);
// POST /run-stream and read the SSE frames. The frame name is on the `event:`
      // line; `delta` payloads carry {"text":"..."} and concatenate into the page JSON.
      req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(bodyBytes))
      req.Header.Set("Content-Type", "application/json")
      req.Header.Set("Authorization", "Bearer "+token)
      req.Header.Set("Idempotency-Key", idemKey(inp, 1))
      res, err := http.DefaultClient.Do(req)
      if err != nil {
          panic(err)
      }
      defer res.Body.Close()
      
      sc := bufio.NewScanner(res.Body)
      sc.Buffer(make([]byte, 1<<20), 1<<20)
      var raw strings.Builder
      event := ""
      for sc.Scan() {
          line := sc.Text()
          switch {
          case strings.HasPrefix(line, "event:"):
              event = strings.TrimSpace(line[6:])
          case strings.HasPrefix(line, "data:"):
              payload := strings.TrimSpace(line[5:])
              if event == "delta" {
                  var d struct{ Text string `json:"text"` }
                  json.Unmarshal([]byte(payload), &d)
                  raw.WriteString(d.Text)
              } else if event == "done" {
                  fmt.Println("settled:", payload)
                  fmt.Println("page:", raw.String())
                  return
              }
          }
      }
// POST /run-stream with BodyHandlers.ofLines() and fold the SSE frames yourself.
      HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
          .header("Content-Type", "application/json")
          .header("Authorization", "Bearer " + token)
          .header("Idempotency-Key", key)
          .POST(HttpRequest.BodyPublishers.ofString(reviewInputJson))
          .build();
      
      StringBuilder raw = new StringBuilder();
      String[] event = { "" };
      HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
          if (line.startsWith("event:")) {
              event[0] = line.substring(6).trim();
          } else if (line.startsWith("data:") && event[0].equals("delta")) {
              // parse {"text":"..."} with your JSON library and append it
              raw.append(extractText(line.substring(5).trim()));
          }
      });
      System.out.println(raw);   // the page JSON
def run_stream(inp, token, attempt = 1)
        uri = URI(BASE.to_s + "/run-stream")
        req = Net::HTTP::Post.new(uri)
        req["Content-Type"] = "application/json"
        req["Authorization"] = "Bearer #{token}"
        req["Idempotency-Key"] = idem_key(inp, attempt)
        req.body = JSON.generate(inp)
        raw = ""
        event = nil
        settle = nil
        Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
          http.request(req) do |res|
            res.read_body do |chunk|
              chunk.each_line do |line|
                line = line.chomp
                if line.start_with?("event:")
                  event = line[6..].strip
                elsif line.start_with?("data:")
                  payload = JSON.parse(line[5..].strip.empty? ? "{}" : line[5..].strip)
                  raw << payload.fetch("text", "") if event == "delta"
                  settle = payload if event == "done"
                end
              end
            end
          end
        end
        [JSON.parse(raw), settle]
      end
      
      page, settle = run_stream(review_input, token)
      puts "#{page['command']} charged #{settle['charged_credits']}"
// POST /run-stream with a write callback; the frame name arrives on `event:`.
      $raw = "";
      $event = "";
      $ch = curl_init(BASE . "/run-stream");
      curl_setopt_array($ch, [
          CURLOPT_POST => true,
          CURLOPT_POSTFIELDS => json_encode($review_input),
          CURLOPT_HTTPHEADER => [
              "Content-Type: application/json",
              "Authorization: Bearer " . $token,
              "Idempotency-Key: " . idem_key($review_input),
          ],
          CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
              foreach (explode("\n", $chunk) as $line) {
                  $line = rtrim($line);
                  if (str_starts_with($line, "event:")) {
                      $event = trim(substr($line, 6));
                  } elseif (str_starts_with($line, "data:") && $event === "delta") {
                      $payload = json_decode(trim(substr($line, 5)), true) ?: [];
                      $raw .= $payload["text"] ?? "";
                  }
              }
              return strlen($chunk);
          },
      ]);
      curl_exec($ch);
      curl_close($ch);
      $page = json_decode($raw, true);
      echo $page["command"] . "\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
          Content = JsonContent.Create(reviewInput)
      };
      sreq.Headers.Add("Authorization", "Bearer " + token);
      sreq.Headers.Add("Idempotency-Key", IdemKey(reviewInput));
      
      using var sres = await Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
      using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
      var raw = new StringBuilder();
      string? evt = null, line;
      while ((line = await reader.ReadLineAsync()) != null) {
          if (line.StartsWith("event:")) {
              evt = line[6..].Trim();
          } else if (line.StartsWith("data:")) {
              var payload = JsonDocument.Parse(line[5..].Trim() is { Length: > 0 } s ? s : "{}");
              if (evt == "delta" && payload.RootElement.TryGetProperty("text", out var t))
                  raw.Append(t.GetString());
              else if (evt == "done")
                  Console.WriteLine("settled: " + payload.RootElement);
          }
      }
      Console.WriteLine(raw.ToString());

Step 6 · Read the review history — and search it by meaning

Past reviews are stored in a declared collection named reviews, with title, summary, banner, article_type, method_families, reviewer_count, reconcile_flags and ran_at as indexed fields, and title and summary as the embedded (vector-searchable) ones. Every where entry must be an operator object ({"eq": …}); a bare value is rejected. Operators: eq ne lt lte gt gte in contains. Records are scoped to the calling subject, and each POST /guest mints a new guest identity, so reuse one token across writes and reads. The full model result, the rendered review Markdown and the free-lane report ride along as undeclared keys — stored and returned intact, just not filterable. The manuscript itself is never stored: a paper does not fit the 64 KB per-document cap and it is the author's own file, so the record keeps the verdict and the measurements, not the text.

Writing records. The query endpoint is POST /collections/reviews/query, but the record CRUD paths sit under /records and wrap the document in a doc envelope:
POST /collections/reviews/records with {"doc": {…}}{"data":{"record":{"record_id":"rec_…"}}}
GET /collections/reviews/records/{record_id} · PUT /collections/reviews/records/{record_id} · DELETE /collections/reviews/records/{record_id}
Semantic search is POST /collections/reviews/similar with {"text": "the mouse tumour paper with the weak controls", "limit": 8} — each hit carries a cosine score. It is rate-limited to 30 requests/minute per IP and costs roughly ten times a filtered query, so debounce it and prefer where whenever an exact match would do. Indexing is asynchronous and only records written after the collection was declared are searchable — there is no backfill, which is why title and summary were chosen as the embed fields before the app had any users.
# Filtered query: every manuscript that came back needing a major revision, newest first.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/reviews/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"where":{"banner":{"eq":"Major revision"},"reconcile_flags":{"eq":0}},
       "sort":{"field":"ran_at","dir":"desc"},"limit":20}'

# Semantic search over title + summary (30/min per IP; ~10x a query):
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/reviews/similar \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"text":"the mouse tumour paper with the weak controls","limit":8}'
res = call("/collections/reviews/query", body={
    "where": {"reviewer_count": {"gte": 4}},
    "sort": {"field": "ran_at", "dir": "desc"},
    "limit": 24,
}, token=token)
for rec in res["records"]:
    d = rec["doc"]
    print(d["ran_at"], d["title"], "-", d["banner"], "-", d["reviewer_count"], "reviewers")

hits = call("/collections/reviews/similar",
            body={"text": "the mouse tumour paper with the weak controls", "limit": 8},
            token=token)
for rec in hits["records"]:
    print("%.2f" % rec.get("score", 0), rec["doc"]["title"])
const res = await call("/collections/reviews/query", {
  token,
  body: {
    where: { reviewer_count: { gte: 4 } },
    sort: { field: "ran_at", dir: "desc" },
    limit: 24,
  },
});
for (const rec of res.records) {
  const d = rec.doc;
  console.log(d.ran_at, d.title, "-", d.banner, "-", d.reviewer_count + " reviewers");
}

const hits = await call("/collections/reviews/similar", {
  token,
  body: { text: "the mouse tumour paper with the weak controls", limit: 8 },
});
for (const rec of hits.records) console.log(rec.score, rec.doc.title);
// POST /collections/reviews/query with an operator object per where field.
query := map[string]any{
    "where": map[string]any{"reviewer_count": map[string]any{"gte": 4}},
    "sort":  map[string]string{"field": "ran_at", "dir": "desc"},
    "limit": 24,
}
var res struct {
    Records []struct {
        RecordID string         `json:"record_id"`
        Doc      map[string]any `json:"doc"`
    } `json:"records"`
}
if err := call("/collections/reviews/query", token, query, &res); err != nil {
    panic(err)
}
for _, r := range res.Records {
    fmt.Println(r.Doc["ran_at"], r.Doc["title"], r.Doc["banner"])
}
// POST /collections/reviews/query
String q = "{\"where\":{\"reviewer_count\":{\"gte\":4}}," +
           "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":24}";
System.out.println(call("/collections/reviews/query", token, q));
// Semantic search: POST /collections/reviews/similar {"text":"...","limit":8}
res = call("/collections/reviews/query", body: {
  "where" => { "reviewer_count" => { "gte" => 4 } },
  "sort" => { "field" => "ran_at", "dir" => "desc" },
  "limit" => 24,
}, token: token)
res["records"].each do |rec|
  d = rec["doc"]
  puts "#{d['ran_at']} #{d['title']} - #{d['banner']} - #{d['reviewer_count']} reviewers"
end
$res = call("/collections/reviews/query", [
    "where" => ["reviewer_count" => ["gte" => 4]],
    "sort" => ["field" => "ran_at", "dir" => "desc"],
    "limit" => 24,
], $token);
foreach ($res["records"] as $rec) {
    $d = $rec["doc"];
    echo "{$d['ran_at']} {$d['title']} - {$d['banner']}\n";
}
var q = new {
    where = new { reviewer_count = new { gte = 4 } },
    sort = new { field = "ran_at", dir = "desc" },
    limit = 24
};
var res = await Call("/collections/reviews/query", q, token);
foreach (var rec in res.GetProperty("records").EnumerateArray()) {
    var d = rec.GetProperty("doc");
    Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("title")}");
}

The input schema

These are the exact fields the app submits. The manuscript is measured locally before the run: everything the free engine counts becomes a fact in prescan, and that is what the reply is held to — a figure, table, section, reference or statistic the review cites but the manuscript does not contain is printed by name next to the rendered review. Long manuscripts are clipped from the middle for the wire (the app keeps the first ~26k and last ~12k characters, cut on paragraph boundaries, with a marker naming the gap), because a paper's contribution lives in its abstract, introduction and discussion; prescan is computed from the full text, so nothing the engine measured is lost. A client that computes no prescan may send an empty object; the review still gets written, it simply has nothing to be reconciled against — which is the whole point of the free lane.

FieldTypeMeaning
titlestringThe manuscript title. Falls back to the first detected section name.
target_journalstringOptional venue. Calibrates expectations; empty means a selective general-science journal.
notesstringOptional guidance: what to focus on, known weaknesses.
manuscriptstringThe manuscript as Markdown, middle-clipped when long. Cut markers read [… N characters cut from the MIDDLE …].
current_datetimestringThe caller's local time — used for the report date only.
prescan.words_totalnumberWord count of the whole manuscript, not of what was sent.
prescan.sectionsarray{name, canonical, words} per detected section. canonical is one of abstract introduction methods results discussion references acknowledgements supplementary other front.
prescan.section_wordsobjectWord totals keyed by canonical section id.
prescan.missing_sectionsarrayCanonical sections not found at all — a measured fact, so the review may assert absence without it counting as an invention.
prescan.word_checksarray{section, words, verdict, target, note} for each section outside its typical range.
prescan.figures, tablesarray{id, caption, has_caption, mentions} — the complete inventory, and how often the running text cites each. mentions counts citations in the running text only: a caption's own label (“Figure 1. …”) is excluded, so a captioned figure nobody refers to correctly reports mentions: 0 and appears in figure_check.defined_not_mentioned.
prescan.figure_check, table_checkobject{mentioned_not_defined, defined_not_mentioned} — cited without a caption, and captioned but never cited.
prescan.referencesobject{present, style, count, issues}. style is numeric, author-year, keyed (BibTeX) or unknown; issues lists measured format inconsistencies.
prescan.citationsobject{checked, how, cited_missing, uncited} — the cross-check in both directions. cited_missing resolves to nothing in the list; uncited is listed but never cited.
prescan.statsobjectp-value and sample-size counts and the literal values found, CI and effect-size term counts, whether power, multiple-testing correction, randomization and blinding are mentioned, plus measured flags.
prescan.statementsobjectBooleans for ethics, data_availability, code_availability, conflict_of_interest, funding, preregistration.
prescan.styleobject{sentences, avg_sentence_words, flesch_reading_ease, passive_pct, hedges_per_1000_words}.
prescan.fingerprintobject{families, family_scores, article_type, reviewer_set} — the detected method families and the reviewer panel drawn from the source project's six manuscript→reviewer-set mappings. The reply is expected to use reviewer_set.
prescan.sourceobjectHow the text was obtained: {label, detected_by, mismatch, latex_includes}. mismatch is non-empty when the file's name lied about its bytes; latex_includes names \input/\include files that were not followed.
prescan.clippingobject{chars_total, chars_sent, chars_cut_from_middle, clipped} — declared clipping, so the reply knows what it never saw.
retry_notestringSend only when re-asking after a malformed reply, with the same idempotency key seed and a bumped attempt counter.

A complete body

{
  "title": "Plasma p-tau217 positivity in a community-based cohort of 11,024 adults",
  "target_journal": "Nature Aging",
  "notes": "We know the cutoff comes from a memory-clinic cohort. Focus on whether the prevalence claim and the treatment-eligibility sentence are supportable.",
  "manuscript": "# Plasma p-tau217 positivity in a community-based cohort of 11,024 adults\n\n## Abstract\n\nBlood-based biomarkers ... [the full manuscript text, clipped head-and-tail when long] ...\n\n## References\n\n1. Andersen, K. et al. Blood biomarkers for Alzheimer disease. Lancet Neurol 2023.",
  "current_datetime": "2026-08-07T14:30:00+08:00",
  "prescan": {
    "words_total": 852,
    "sections": [
      {
        "name": "Abstract",
        "canonical": "abstract",
        "words": 163
      },
      {
        "name": "Methods",
        "canonical": "methods",
        "words": 160
      }
    ],
    "section_words": {
      "other": 0,
      "abstract": 121,
      "introduction": 114,
      "methods": 160,
      "results": 179,
      "discussion": 166,
      "references": 95
    },
    "missing_sections": [],
    "word_checks": [
      {
        "section": "introduction",
        "words": 114,
        "verdict": "short",
        "target": "300–1500",
        "note": ""
      }
    ],
    "figures": [
      {
        "id": "1",
        "caption": "Distribution of plasma p-tau217 concentration by age decade, with the applied positivity",
        "has_caption": true,
        "mentions": 2
      },
      {
        "id": "2",
        "caption": "Weighted and unweighted prevalence of plasma p-tau217 positivity above age 70.",
        "has_caption": true,
        "mentions": 2
      }
    ],
    "tables": [
      {
        "id": "1",
        "caption": "Plasma p-tau217 positivity by age band in the unweighted sample.",
        "has_caption": true,
        "mentions": 2
      }
    ],
    "figure_check": {
      "mentioned_not_defined": [],
      "defined_not_mentioned": []
    },
    "table_check": {
      "mentioned_not_defined": [],
      "defined_not_mentioned": []
    },
    "references": {
      "present": true,
      "style": "numeric",
      "count": 6,
      "issues": []
    },
    "citations": {
      "checked": true,
      "how": "bracketed numbers vs numbered entries",
      "cited_missing": [
        7
      ],
      "uncited": [
        5,
        6
      ]
    },
    "stats": {
      "p_count": 2,
      "p_values": [
        "p < 0.001",
        "p < 0.001"
      ],
      "ci_count": 2,
      "effect_terms": 0,
      "n_count": 0,
      "n_values": [],
      "power_mentioned": false,
      "correction_mentioned": false,
      "randomization_mentioned": false,
      "blinding_mentioned": true,
      "flags": [
        "p-values are reported but no sample size (n = …) was found."
      ]
    },
    "statements": {
      "ethics": true,
      "data_availability": true,
      "code_availability": true,
      "conflict_of_interest": true,
      "funding": true,
      "preregistration": false
    },
    "style": {
      "sentences": 57,
      "avg_sentence_words": 12.5,
      "flesch_reading_ease": 27.6,
      "passive_pct": 28.1,
      "hedges_per_1000_words": 2.8
    },
    "fingerprint": {
      "families": [
        "clinical"
      ],
      "family_scores": {
        "wet-lab": 0,
        "clinical": 6,
        "ML": 0,
        "omics": 0,
        "imaging": 0,
        "simulation": 0,
        "data-resource": 0,
        "theory": 0
      },
      "article_type": "Article",
      "reviewer_set": [
        "Clinical Validity Reviewer",
        "Statistical Reviewer",
        "Ethics & Governance Reviewer",
        "Figures & Tables Reviewer"
      ]
    },
    "source": {
      "label": "Word (.docx)",
      "detected_by": "[Content_Types].xml declares WordprocessingML",
      "mismatch": "",
      "latex_includes": []
    },
    "clipping": {
      "chars_total": 61240,
      "chars_sent": 38112,
      "chars_cut_from_middle": 23128,
      "clipped": true
    }
  }
}

The output contract

The reply is one JSON object and nothing else. Parse defensively anyway: strip a stray code fence, take the span from the first { to the last }, and re-ask once with a retry_note and the same idempotency seed if it does not parse. These are the fields the app's own render path requires and the constraints ReviewKit.normalize() enforces — some are hard rejections that trigger the retry, others are coerced with a warning the UI prints.

FieldConstraint
bannerExactly one of Major revision, Accept with minor revisions, Reject in present form, Cannot be assessed. A near-miss is coerced to the closest band with a warning; anything unrecognizable is a hard rejection.
editor_letterNon-empty array of 1–4 strings. Extras are dropped. If the first paragraph does not state the band it is prefixed with it, and an opening thank-you earns a warning — the source contract requires the band first.
reviewers2–5 objects; fewer is a hard rejection, more are dropped. Each needs a role (a role label, not a person — anything that looks like a name or title is sanitized), an overall paragraph (missing becomes [Overall missing — please regenerate]), a confidence of high|medium|low (else medium), and at least one concern.
reviewers[].major, .minorArrays of {heading, body, evidence, axis}. heading and body are required — a missing one is a hard rejection. axis must be one of the 12 (novelty-significance mechanism-evidence experimental-design statistical-rigor reproducibility clinical-validity ethical-governance data-resource-quality figures-and-tables writing-clarity claim-moderation mechanistic-vs-correlative) or it is cleared. A major concern with no evidence pointer earns a warning.
consensusArray of {description, raised_by, axis, severity, rationale}. Entries with fewer than two raised_by reviewers are dropped — the source contract emits consensus only, never a divergence block. severity is major|minor|minor-major. Ids are assigned as Cc.1, Cc.2
tasksArray of {id, reviewer, concern, strategy, status, input_needed, expected_output, blocks_response}. strategy must come from the source project's closed 20-strategy vocabulary or it is flagged; status must be one of the 8, and DONE is always downgraded to DRAFTED because a fresh review has no revised manuscript to verify against. blocks_response is coerced to Yes/No.
coverage_notesArray, may be empty but must be present. Where the reply says what it could not assess — clipped methods, absent figures, missing data.
Two prohibitions matter most. No invented pointers: every figure, table, section, reference number, p-value and sample size the review cites must exist in the manuscript or in prescan. ReviewKit.reconcile(review, facts, text) re-checks this mechanically and returns one entry per violation ({kind, pointer, note} with kind in figure table section reference statistic consensus), which the app prints under Reconciliation. It deliberately does not flag a pointer the free lane already measured as missing — naming an unresolved citation is the job, not a hallucination. And no decision prediction: the four bands are the only verdict; phrasing like “the editor will likely…” is detected and warned about, per the source project's adversarial checklist.

The free lane is client-side, and you can have it too

The measurement engine ships with the app as static files and calls no network: manuscript.js (the measurements), reviewkit.js (the review contract, the fingerprint, normalization and reconciliation), latex.js (the LaTeX reader) and the vendored document readers convert.js, sniff.js, pdftext.js, docx.js and their dependencies.

CallWhat it does
Convert.run(bytes, filename, opts)Reads a document from raw bytes — PDF text layer, .docx, .odt/.ods/.odp, .rtf, .epub, .html, .csv, .json, .md, .txt — detecting the format from magic bytes. Resolves {ok, kind, label, detected_by, mismatch, markdown, facts, warnings}; mismatch is populated when the extension lied.
LatexConv.convert(texSource)Returns {markdown, facts, warnings} for LaTeX: sectioning commands become headings, \begin{abstract} becomes a section, figure and table environments become numbered captions, \ref resolves through \label, \cite keys and \bibitem keys are collected for the cross-check, comments are stripped, and \input/\include are recorded rather than silently dropped.
MsKit.analyze(markdown, {latex})The whole free report as one object: sections, word checks, figure and table inventory with cross-checks, references with style and issues, the citation cross-check, statistical scan, statement booleans, readability and hedging, and an identifier sniff.
ReviewKit.fingerprint(text, facts){families, family_scores, article_type, reviewer_set} — the panel the source project's mappings prescribe for this paper.
ReviewKit.normalize(parsed)Enforces the output contract above; throws on a hard rejection, returns coercion warnings otherwise.
ReviewKit.reconcile(review, facts, text)Every unverified pointer the review made, as {kind, pointer, note}.
ReviewKit.toMarkdown(review, meta)The rendered review: editor letter with the band, per-reviewer sections, consensus block, revision task table.
ReviewKit.factsToMarkdown(facts, fp, meta)The free structural report as Markdown — the deliverable the app hands out before any sign-in. Built from structuralRows() and citationRows(), so it is the same table the page renders.
ReviewKit.structuralRows(facts, fp, targets)The structural report as table rows: an array of {group, check, present, measured, expected, status}. group is one of Structure, Figures & tables, References, Statistics, Statements, Writing, Anonymity, Panel; present is yes, no or n/a; status is pass, warn, fail or info. This is the single row model the on-screen table, the Markdown export, the CSV export and the JSON export all read, so none of them can disagree with another.
ReviewKit.citationRows(facts)The citation cross-check as its own rows: {group, dir, marker, check, present, measured, expected, status}. dir is cited → list for a citation that resolves to nothing, list → cited for an entry the text never cites, both directions for the single pass row a clean manuscript gets, or cross-check when the check could not run. Never an empty array.
ReviewKit.problemRows(rows)The subset of rows whose status is fail or warn, in table order — what the page's “only rows needing attention” filter shows.
ReviewKit.rowsToMarkdownTable(rows)Rows → a GitHub-flavoured Markdown table with the six columns above. Pipes and newlines inside a cell are neutralised so the table cannot break.
ReviewKit.rowsToCsv(rows)Rows → CSV with the header group,check,present,measured,expected,status, RFC-4180 quoting and a trailing newline. This is exactly what the app's Download checklist .csv button produces, over the structural rows concatenated with the citation rows.
Reading the modules in Node needs only a window stub (global.window = global) and, for Convert.run, a performance.now shim; the measurements are pure string work with no I/O. The same manuscript always measures to the same facts and the same review always reconciles to the same flags, so a CI job can gate a preprint repo on analyze() reporting an empty citations.cited_missing and every figure captioned — without spending anything.