Grant Panel — API

Paste the proposal — sit in the study section before the study section does.

API tokens Open the app

Run a mock study section from your own scripts

Send one section of a proposal — a Specific Aims page, a project summary, a research strategy, a broader-impacts statement or a budget justification — name the agency, and get back one JSON object: a competitive / revise / not-competitive recommendation, an overall score on the 1–9 NIH scale, a rating per review criterion in that agency's own vocabulary, a panel summary written the way a summary statement is written, findings that each quote the verbatim line of your own text they rest on, and a revision plan with rewritten aims. Everything the app does goes through the SkillSafe App API — plain JSON over HTTPS — so the panel can sit wherever a proposal is drafted: a pre-submission gate in a lab's repo, a program officer's triage script, or a batch pass over every draft in a research-office folder. Pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug grant-panel. 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 — never a bare object, so unwrap once in a helper and forget about it. Estimates are free; runs are metered against your credit balance. There is a single run task — one section in, one review out, no follow-up calls and no session state to carry between them.

StatusCodeMeaning
401unauthorizedMissing, malformed or expired token — mint a new one (step 1) and retry once. Guest tokens expire; personal tokens are revoked from the token page.
402payment_requiredNot enough credits to place the hold for this review — top up at skillsafe.ai/account/credits. The estimate's min_credits is the floor below which a run will not start at all.
404not_foundUnknown job id, unknown record id, or one belonging to another subject. Jobs and collection records are readable only by the subject that created them.
409conflictAn Idempotency-Key you have already used was replayed with a different body. Keys are bound to the exact request that first used them — derive the key from a hash of the payload and this cannot happen.
422validation_errorThe body did not validate — text missing or empty, or a bad enum in agency or section. The message names the offending field. The app additionally refuses text under 200 characters client-side; that is a UI rule, not a server one.
429rate_limitedToo many requests in flight for this subject (or, for /similar, more than 30 searches a minute from one IP). Back off and retry — reuse the same Idempotency-Key so a retry cannot start a second, double-charged run.
5xxTransient platform error — retry with backoff, again with the same Idempotency-Key. A run already in flight keeps going; the replay returns the original job.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend. And a standing caveat worth putting in whatever you build on top: this is a simulated panel. It is calibrated against published review criteria, not a decision from any agency, and no real study section is bound by anything it says.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the {data}/{error} envelope. Set SKILLSAFE_TOKEN in your environment — the token page can copy a ready-made export line, so nothing here needs a browser developer console.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"   # from /tokens.html, or step 1 below

# every call: curl -s "$API/<path>" -H "Authorization: Bearer $TOKEN" \
#   -H "Content-Type: application/json" [-d "$BODY"] | jq '.data'
import json, os, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ["SKILLSAFE_TOKEN"]   # or paste "YOUR_TOKEN" while trying this out

def api(method, path, body=None, **kw):
    r = requests.request(method, API + path, json=body,
        headers={"Authorization": f"Bearer {TOKEN}"}, timeout=300, **kw)
    envelope = r.json()
    if "error" in envelope:
        raise RuntimeError(f"{envelope['error']['code']}: {envelope['error']['message']}")
    return envelope["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // read it from your env or secret store

async function api(method, path, body, extraHeaders) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      "Content-Type": "application/json",
      ...(extraHeaders || {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const envelope = await res.json();
  if (envelope.error) throw new Error(`${envelope.error.code}: ${envelope.error.message}`);
  return envelope.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

func api(method, path string, body any, out any) error {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return err
		}
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var envelope struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Code, Message string } `json:"error"`
	}
	if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
		return err
	}
	if envelope.Error != nil {
		return fmt.Errorf("%s: %s", envelope.Error.Code, envelope.Error.Message)
	}
	if out != nil {
		return json.Unmarshal(envelope.Data, out)
	}
	return nil
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
import java.net.URI;
import java.net.http.*;

public class SkillSafe {
  static final String API = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
  static final HttpClient http = HttpClient.newHttpClient();

  static String api(String method, String path, String jsonBody) throws Exception {
    var b = HttpRequest.newBuilder(URI.create(API + path))
        .header("Authorization", "Bearer " + TOKEN)
        .header("Content-Type", "application/json");
    b = jsonBody == null ? b.GET()
        : b.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
    var res = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (res.body().contains("\"error\""))
      throw new RuntimeException(res.body());
    return res.body(); // {"data": …} — unwrap with your JSON library
  }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")

def api(method, path, body = nil, headers = {})
  uri = URI(API + 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.hostname, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
  envelope = JSON.parse(res.body)
  raise "#{envelope.dig('error', 'code')}: #{envelope.dig('error', 'message')}" if envelope["error"]
  envelope["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN");

function api(string $method, string $path, ?array $body = null, array $extra = []) {
  global $TOKEN;
  $ch = curl_init(API . $path);
  curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => $method,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 300,
    CURLOPT_HTTPHEADER => array_merge([
      "Authorization: Bearer $TOKEN",
      "Content-Type: application/json",
    ], $extra),
  ]);
  if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
  $envelope = json_decode(curl_exec($ch), true);
  curl_close($ch);
  if (isset($envelope["error"]))
    throw new RuntimeException($envelope["error"]["code"] . ": " . $envelope["error"]["message"]);
  return $envelope["data"];
}
// .NET 8+
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe {
  const string Api = "https://api.skillsafe.ai/v1/app-api";
  static readonly string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!;
  static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };

  public static async Task<JsonElement> ApiAsync(HttpMethod method, string path,
                                                 object? body = null, string? idempotencyKey = null) {
    var req = new HttpRequestMessage(method, Api + path);
    req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
    if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey);
    if (body is not null) req.Content = JsonContent.Create(body);
    var res = await Http.SendAsync(req);
    var envelope = await res.Content.ReadFromJsonAsync<JsonElement>();
    if (envelope.TryGetProperty("error", out var err))
      throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
    return envelope.GetProperty("data");
  }
}

Step 1 — Get a token

POST /guest

Two kinds of token work here. A personal token is the one the app itself uses once you sign in: open the token page, which mints, lists and revokes tokens and hands you a copy-ready export SKILLSAFE_TOKEN=… line. That page exists precisely so nobody has to go digging through a browser's developer tools for a credential — a token pulled out of storage by hand is not revocable on its own and is trivially leaked into a screenshot. Metered runs on a personal token bill your account.

A guest token can be minted by any script with no browser at all. It can call /me and the free /estimate, and it can run only if the app sponsors usage — check sponsor_enabled in the estimate before assuming it does. Each POST /guest mints a new identity, so if you intend to write and then read back review history (step 6), hold on to one guest token for the whole session rather than minting a fresh one per call.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "grant-panel"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "grant-panel"})["token"]
const { token } = await api("POST", "/guest", { slug: "grant-panel" });
var guest struct{ Token string `json:"token"` }
err := api("POST", "/guest", map[string]string{"slug": "grant-panel"}, &guest)
String envelope = api("POST", "/guest", """
  {"slug": "grant-panel"}
  """); // parse .data.token from the envelope
token = api("POST", "/guest", { slug: "grant-panel" })["token"]
$token = api("POST", "/guest", ["slug" => "grant-panel"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "grant-panel" });
var token = guest.GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me

Returns subject_type (user or guest), subject_id and credits. Compare credits against the estimate's hold_credits before running — a script that submits a run it cannot afford earns a 402 it could have predicted, and a batch job that checks once at the top can stop cleanly instead of failing halfway through a folder of drafts.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
# {"subject_type":"user","subject_id":"usr_…","credits":50000,…}
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
err := api("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// parse .data.subject_type and .data.credits
me = api("GET", "/me")
puts "#{me['subject_type']} #{me['credits']}"
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"];
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 — Estimate the cost, and see the model binding

POST /estimate

Free, and worth calling every time: the body is exactly the body you would send to /run, and the reply prices it before you commit. Fields returned:

FieldTypeMeaning
modelstringThe exact model version this app is pinned to for the run.
model_aliasstringThe stable alias behind that version. Assert on this, not on model, if you want a run to fail loudly when the binding changes under you.
markup_bpsintegerThe app's markup over raw inference cost, in basis points (0 means at cost, 1000 means +10%).
hold_creditsintegerThe worst-case amount reserved while the run is in flight — not the price. It prices the full output cap; when the run settles you are charged only what it actually used, typically far less.
min_creditsintegerThe floor below which the run will not start. If credits from /me is under this, you get a 402.
sponsor_enabledbooleanWhether the app sponsors this run — i.e. whether a guest token can execute /run at all. Check it before building anything on guest tokens.
BODY=$(jq -n --rawfile text aims.txt '{
  agency: "nih",
  mechanism: "R01",
  section: "aims",
  field: "computational neuroscience",
  text: $text
}')

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$BODY" | jq '.data'
# {"model":"…","model_alias":"…","markup_bps":…,"hold_credits":…,
#  "min_credits":…,"sponsor_enabled":false}
payload = {
    "agency": "nih",          # nsf | nih | doe | darpa
    "mechanism": "R01",       # free text: R01, R21, CAREER, DE-FOA-0000000…
    "section": "aims",        # aims | summary | narrative | impacts | budget
    "field": "computational neuroscience",
    "text": open("aims.txt").read(),
}
est = api("POST", "/estimate", payload)
print(f"model {est['model']} ({est['model_alias']}), markup {est['markup_bps']} bps")
print(f"hold {est['hold_credits']}, floor {est['min_credits']}, sponsored {est['sponsor_enabled']}")

me = api("GET", "/me")
assert me["credits"] >= est["min_credits"], "top up before running"
import { readFileSync } from "node:fs";

const payload = {
  agency: "nih",        // nsf | nih | doe | darpa
  mechanism: "R01",
  section: "aims",      // aims | summary | narrative | impacts | budget
  field: "computational neuroscience",
  text: readFileSync("aims.txt", "utf8"),
};
const est = await api("POST", "/estimate", payload);
console.log(`model ${est.model} (${est.model_alias}), markup ${est.markup_bps} bps`);
console.log(`hold ${est.hold_credits}, floor ${est.min_credits}, sponsored ${est.sponsor_enabled}`);
text, _ := os.ReadFile("aims.txt")
payload := map[string]any{
	"agency":    "nih",
	"mechanism": "R01",
	"section":   "aims",
	"field":     "computational neuroscience",
	"text":      string(text),
}
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"`
	SponsorEnabled bool   `json:"sponsor_enabled"`
}
if err := api("POST", "/estimate", payload, &est); err != nil {
	panic(err)
}
fmt.Printf("hold %d, floor %d, model %s\n", est.HoldCredits, est.MinCredits, est.Model)
String text = java.nio.file.Files.readString(java.nio.file.Path.of("aims.txt"));
// Build the JSON with your library; fields: agency, mechanism, section, field, text
String payload = toJson(java.util.Map.of(
    "agency", "nih",
    "mechanism", "R01",
    "section", "aims",
    "field", "computational neuroscience",
    "text", text));
String envelope = api("POST", "/estimate", payload);
// read .data.hold_credits, .data.min_credits, .data.model_alias, .data.sponsor_enabled
payload = {
  agency: "nih",
  mechanism: "R01",
  section: "aims",
  field: "computational neuroscience",
  text: File.read("aims.txt"),
}
est = api("POST", "/estimate", payload)
puts "model #{est['model']} (#{est['model_alias']}), markup #{est['markup_bps']} bps"
puts "hold #{est['hold_credits']}, floor #{est['min_credits']}, sponsored #{est['sponsor_enabled']}"
$payload = [
  "agency" => "nih",
  "mechanism" => "R01",
  "section" => "aims",
  "field" => "computational neuroscience",
  "text" => file_get_contents("aims.txt"),
];
$est = api("POST", "/estimate", $payload);
echo "model {$est['model']} ({$est['model_alias']}), markup {$est['markup_bps']} bps\n";
echo "hold {$est['hold_credits']}, floor {$est['min_credits']}\n";
var payload = new {
  agency = "nih",
  mechanism = "R01",
  section = "aims",
  field = "computational neuroscience",
  text = await File.ReadAllTextAsync("aims.txt"),
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"model {est.GetProperty("model")} ({est.GetProperty("model_alias")})");
Console.WriteLine($"hold {est.GetProperty("hold_credits")}, floor {est.GetProperty("min_credits")}, "
                + $"sponsored {est.GetProperty("sponsor_enabled")}");

The estimate scales with the length of text (plus prior_critique if you send it), so price the actual section you mean to review, not a stand-in. A Specific Aims page and a fifteen-page research strategy are not the same run.

The input schema — every field

The same body serves /estimate, /run and /run-stream. Only text is required; everything else sharpens the review.

FieldTypeMeaning
agencyenum, optionalnsf · nih · doe · darpa. Selects the review-criterion vocabulary the panel scores against (see the output contract below) and the register of the critique. Defaults to nih.
mechanismstring, optionalFree text naming the funding mechanism or solicitation — R01, R21, U01, CAREER, DE-FOA-0000000, HR001124S0001. Used to set expectations about scope, budget and preliminary data; an aims page that would fly as an R21 can read as thin for an R01.
sectionenum, optionalWhich part of the proposal this text is, so the panel judges it against the right job. aims (NIH Specific Aims / objectives page), summary (project summary / abstract), narrative (project description / research strategy), impacts (NSF Broader Impacts / NIH Significance), budget (budget justification). Defaults to aims.
fieldstring, optionalThe research field, in the applicant's own words — computational neuroscience, solid-state batteries, science education research. Calibrates what counts as novel and which conventions apply.
textstring, requiredThe proposal narrative being reviewed. Plain text; markup is tolerated but adds noise to the quoted evidence. A 422 comes back if it is missing or empty.
prior_critiquestring, optionalReviewer comments from a previous submission. When present the panel is explicitly asked whether each earlier concern has been addressed in the current text, and says so where it has not — the single most useful field for a resubmission.
prescan_factsobject, optionalWhat the free client-side scanner (grantscan.js) measured, in two parts: stats and flags. This is the ground truth the model must reconcile against — see below.

prescan_facts

Computed in the browser before any credits are spent, and sent along so the review cannot contradict arithmetic. stats is a flat object of measurements; flags is a list of {id, label} rule hits, where id is family:slug (for example rigor:no-alternatives).

{
  "agency": "nih",
  "mechanism": "R01",
  "section": "aims",
  "field": "computational neuroscience",
  "text": "<the proposal narrative being reviewed>",
  "prior_critique": "<optional: reviewer comments from a previous submission>",
  "prescan_facts": {
    "stats": {"words": 612, "chars": 4180, "est_pages": 0.9, "sentences": 28,
              "avg_sentence_words": 21.9, "long_sentences": 3, "passive_ratio": 0.18,
              "acronyms": 7, "undefined_acronyms": 2, "aims": 3, "citations": 11,
              "figures": 1, "limit_words": 700, "over_limit": false},
    "flags": [{"id": "rigor:no-alternatives", "label": "No alternative approach or contingency named"}]
  }
}
payload = {
    "agency": "nih",
    "mechanism": "R01",
    "section": "aims",
    "field": "computational neuroscience",
    "text": aims_text,
    "prior_critique": previous_summary_statement,   # optional
    "prescan_facts": {
        "stats": {"words": 612, "chars": 4180, "est_pages": 0.9, "sentences": 28,
                  "avg_sentence_words": 21.9, "long_sentences": 3, "passive_ratio": 0.18,
                  "acronyms": 7, "undefined_acronyms": 2, "aims": 3, "citations": 11,
                  "figures": 1, "limit_words": 700, "over_limit": False},
        "flags": [{"id": "rigor:no-alternatives",
                   "label": "No alternative approach or contingency named"}],
    },
}
const payload = {
  agency: "nih",
  mechanism: "R01",
  section: "aims",
  field: "computational neuroscience",
  text: aimsText,
  prior_critique: previousSummaryStatement,   // optional
  prescan_facts: {
    stats: { words: 612, chars: 4180, est_pages: 0.9, sentences: 28,
             avg_sentence_words: 21.9, long_sentences: 3, passive_ratio: 0.18,
             acronyms: 7, undefined_acronyms: 2, aims: 3, citations: 11,
             figures: 1, limit_words: 700, over_limit: false },
    flags: [{ id: "rigor:no-alternatives",
              label: "No alternative approach or contingency named" }],
  },
};
payload := map[string]any{
	"agency":         "nih",
	"mechanism":      "R01",
	"section":        "aims",
	"field":          "computational neuroscience",
	"text":           aimsText,
	"prior_critique": previousSummaryStatement, // optional
	"prescan_facts": map[string]any{
		"stats": map[string]any{
			"words": 612, "chars": 4180, "est_pages": 0.9, "sentences": 28,
			"avg_sentence_words": 21.9, "long_sentences": 3, "passive_ratio": 0.18,
			"acronyms": 7, "undefined_acronyms": 2, "aims": 3, "citations": 11,
			"figures": 1, "limit_words": 700, "over_limit": false,
		},
		"flags": []map[string]string{{
			"id":    "rigor:no-alternatives",
			"label": "No alternative approach or contingency named",
		}},
	},
}
// Build with your JSON library; the shape is:
// {agency, mechanism, section, field, text, prior_critique,
//  prescan_facts: {stats: {...}, flags: [{id, label}]}}
var stats = new java.util.LinkedHashMap<String, Object>();
stats.put("words", 612);
stats.put("chars", 4180);
stats.put("est_pages", 0.9);
stats.put("aims", 3);
stats.put("limit_words", 700);
stats.put("over_limit", false);
var flag = java.util.Map.of("id", "rigor:no-alternatives",
    "label", "No alternative approach or contingency named");
var prescan = java.util.Map.of("stats", stats, "flags", java.util.List.of(flag));
payload = {
  agency: "nih",
  mechanism: "R01",
  section: "aims",
  field: "computational neuroscience",
  text: aims_text,
  prior_critique: previous_summary_statement,   # optional
  prescan_facts: {
    stats: { words: 612, chars: 4180, est_pages: 0.9, sentences: 28,
             avg_sentence_words: 21.9, long_sentences: 3, passive_ratio: 0.18,
             acronyms: 7, undefined_acronyms: 2, aims: 3, citations: 11,
             figures: 1, limit_words: 700, over_limit: false },
    flags: [{ id: "rigor:no-alternatives",
              label: "No alternative approach or contingency named" }],
  },
}
$payload = [
  "agency" => "nih",
  "mechanism" => "R01",
  "section" => "aims",
  "field" => "computational neuroscience",
  "text" => $aimsText,
  "prior_critique" => $previousSummaryStatement,   // optional
  "prescan_facts" => [
    "stats" => ["words" => 612, "chars" => 4180, "est_pages" => 0.9, "sentences" => 28,
                "avg_sentence_words" => 21.9, "long_sentences" => 3, "passive_ratio" => 0.18,
                "acronyms" => 7, "undefined_acronyms" => 2, "aims" => 3, "citations" => 11,
                "figures" => 1, "limit_words" => 700, "over_limit" => false],
    "flags" => [["id" => "rigor:no-alternatives",
                 "label" => "No alternative approach or contingency named"]],
  ],
];
var payload = new {
  agency = "nih",
  mechanism = "R01",
  section = "aims",
  field = "computational neuroscience",
  text = aimsText,
  prior_critique = previousSummaryStatement,   // optional
  prescan_facts = new {
    stats = new { words = 612, chars = 4180, est_pages = 0.9, sentences = 28,
                  avg_sentence_words = 21.9, long_sentences = 3, passive_ratio = 0.18,
                  acronyms = 7, undefined_acronyms = 2, aims = 3, citations = 11,
                  figures = 1, limit_words = 700, over_limit = false },
    flags = new[] { new { id = "rigor:no-alternatives",
                          label = "No alternative approach or contingency named" } },
  },
};
StatTypeMeaning
words · charsintegerLength of text, counted client-side.
est_pagesnumberEstimated printed pages at agency formatting — the number the panel reasons about when it says a section is over-packed.
sentences · avg_sentence_wordsinteger · numberSentence count and mean length.
long_sentencesintegerSentences past the readability threshold. A reviewer reading forty of these at a sitting notices.
passive_rationumber 0–1Fraction of sentences in the passive voice.
acronyms · undefined_acronymsintegerDistinct acronyms used, and how many are never expanded on first use. The second number is the one that costs you.
aimsintegerNumbered aims or objectives detected.
citations · figuresintegerCitation markers and figure references found in the text.
limit_words · over_limitinteger · booleanThe word budget for this section and whether the text exceeds it. When over_limit is true the revision plan is expected to cut, not add.

prescan_facts is optional on the API. Omit it and you get a review with an empty compliance_check — the critique still stands on the full text. Send it and the review is bound to it: every id in prescan_facts.flags must come back in compliance_check, either confirmed (addressed: true) or explicitly overruled with a reason in note. That is the check that keeps a confident-sounding critique honest about what is actually on the page.

Step 4 — Run the review and wait for the verdict

POST /run GET /jobs/{job_id}

POST /run returns a job_id immediately; poll GET /jobs/{job_id} until status is terminal (succeeded or failed). The review object arrives as a JSON string in output.output — parse it and you have exactly what the app renders. The terminal job also carries charged_credits, which is what you actually paid, as opposed to the hold. Always send an Idempotency-Key derived from a hash of the input: a retry after a network blip then replays the original run instead of paying for a second one, and a key reused with a different body is rejected with a 409 rather than silently charging you twice.

KEY="grant-panel:$(shasum -a 256 aims.txt | cut -c1-40)"

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$BODY" | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

echo "$JOB" | jq -r '.data.output.output' > review.json
jq '{recommendation, overall_score, one_line}' review.json
jq -r '.findings[] | "[\(.severity)] \(.criterion) \(.what)"' review.json
import hashlib, json, time

key = "grant-panel:" + hashlib.sha256(
    json.dumps(payload, sort_keys=True).encode()).hexdigest()[:40]
job = api("POST", "/run", payload, headers={"Idempotency-Key": key})

while True:
    j = api("GET", f"/jobs/{job['job_id']}")
    if j["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

assert j["status"] == "succeeded", j.get("error")
review = json.loads(j["output"]["output"])
print(review["recommendation"], review["overall_score"], "-", review["one_line"])
for c in review["criteria"]:
    print(f"  {c['criterion']:<24} {c['rating']:<9} {c['score']}  {c['note']}")
for f in review["findings"]:
    print(f"[{f['severity']}] {f['id']} ({f['criterion']}) {f['what']}")
print("charged", j.get("charged_credits"))
import { createHash } from "node:crypto";

const key = "grant-panel:" + createHash("sha256")
  .update(JSON.stringify(payload)).digest("hex").slice(0, 40);

const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": key });

let job;
do {
  await new Promise(r => setTimeout(r, 2000));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status !== "succeeded") throw new Error(JSON.stringify(job.error));

const review = JSON.parse(job.output.output);
console.log(review.recommendation, review.overall_score, "-", review.one_line);
for (const c of review.criteria) console.log(` ${c.criterion} ${c.rating} ${c.score}`);
for (const f of review.findings) console.log(`[${f.severity}] ${f.id} ${f.what}`);
bodyBytes, _ := json.Marshal(payload)
sum := sha256.Sum256(bodyBytes)
key := "grant-panel:" + hex.EncodeToString(sum[:])[:40]

req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
// … send, decode the envelope, keep data.job_id …

var job struct {
	Status string `json:"status"`
	Output struct{ Output string `json:"output"` } `json:"output"`
	Charged int64 `json:"charged_credits"`
}
for {
	if err := api("GET", "/jobs/"+jobID, nil, &job); err != nil {
		panic(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
var review struct {
	Recommendation string `json:"recommendation"`
	OverallScore   int    `json:"overall_score"`
	OneLine        string `json:"one_line"`
	Criteria []struct {
		Criterion, Rating, Note string
		Score                   int
	} `json:"criteria"`
}
json.Unmarshal([]byte(job.Output.Output), &review)
fmt.Println(review.Recommendation, review.OverallScore, review.OneLine)
String key = "grant-panel:" + sha256Hex(payloadJson).substring(0, 40);
// send POST /run with header Idempotency-Key: key, keep data.job_id

String status = "queued";
while (!status.equals("succeeded") && !status.equals("failed")) {
  Thread.sleep(2000);
  String envelope = api("GET", "/jobs/" + jobId, null);
  status = readField(envelope, "status");   // your JSON library
}
// .data.output.output is a JSON *string* — parse it to get the review object:
//   recommendation, overall_score, criteria[], findings[], revision_plan{…}
require "digest"

key = "grant-panel:" + Digest::SHA256.hexdigest(payload.to_json)[0, 40]
job = api("POST", "/run", payload, { "Idempotency-Key" => key })

j = nil
loop do
  j = api("GET", "/jobs/#{job['job_id']}")
  break if %w[succeeded failed].include?(j["status"])
  sleep 2
end
raise "run #{j['status']}" unless j["status"] == "succeeded"

review = JSON.parse(j["output"]["output"])
puts "#{review['recommendation']} #{review['overall_score']} - #{review['one_line']}"
review["criteria"].each { |c| puts "  #{c['criterion']} #{c['rating']} #{c['score']}" }
$key = "grant-panel:" . substr(hash("sha256", json_encode($payload)), 0, 40);
$job = api("POST", "/run", $payload, ["Idempotency-Key: $key"]);

do {
  sleep(2);
  $j = api("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"], true));
if ($j["status"] !== "succeeded") throw new RuntimeException("run {$j['status']}");

$review = json_decode($j["output"]["output"], true);
echo "{$review['recommendation']} {$review['overall_score']} - {$review['one_line']}\n";
foreach ($review["findings"] as $f)
  echo "[{$f['severity']}] {$f['id']} {$f['what']}\n";
var key = "grant-panel:" + Convert.ToHexString(
    System.Security.Cryptography.SHA256.HashData(
        JsonSerializer.SerializeToUtf8Bytes(payload))).ToLower()[..40];

var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload, key);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
do {
  await Task.Delay(2000);
  job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var review = JsonDocument.Parse(
    job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine($"{review.GetProperty("recommendation")} "
                + $"{review.GetProperty("overall_score")} "
                + $"{review.GetProperty("one_line")}");

A review of a full research strategy can take a couple of minutes. Poll every two seconds and give the loop a ceiling — two hundred iterations is generous — rather than waiting forever on a job that failed upstream. If you want the text as it is written instead of at the end, use /run-stream in step 5.

The review object — output contract

output.output is a JSON string holding one object. Parse it and you get:

{
  "proposal_title": "string",
  "recommendation": "competitive | revise | not-competitive",
  "overall_score": 1,
  "one_line": "string",
  "panel_summary": "string (2-4 paragraphs)",
  "criteria": [{"criterion": "significance", "rating": "strength|mixed|weakness",
                "score": 1, "note": "string"}],
  "findings": [{"id": "R-001", "criterion": "approach", "severity": "high|medium|low",
                "what": "string",
                "evidence": "verbatim quote from the applicant's own text, or empty",
                "why": "string", "fix": "string"}],
  "strengths": ["string"],
  "compliance_check": [{"id": "rigor:no-alternatives", "addressed": true, "note": "string"}],
  "revision_plan": {
    "thesis": "string",
    "actions": [{"order": 1, "target": "string", "action": "string",
                 "effort": "quick|substantial"}],
    "aims": [{"label": "Aim 1", "statement": "string", "rationale": "string",
              "hypothesis": "string", "outcome": "string"}],
    "significance_sentence": "string"
  },
  "summary": "string"
}
review = json.loads(j["output"]["output"])

print(review["proposal_title"])
print(review["recommendation"], review["overall_score"], "-", review["one_line"])
print(review["panel_summary"])

for c in review["criteria"]:
    print(c["criterion"], c["rating"], c["score"], c["note"])
for s in review["strengths"]:
    print("+", s)
for f in review["findings"]:
    print(f"[{f['severity']}] {f['id']} {f['criterion']}: {f['what']}")
    if f["evidence"]:
        print("   quoted:", f["evidence"])
    print("   fix:", f["fix"])

plan = review["revision_plan"]
print(plan["thesis"])
for a in sorted(plan["actions"], key=lambda a: a["order"]):
    print(f"{a['order']}. [{a['effort']}] {a['target']}: {a['action']}")
for aim in plan["aims"]:
    print(aim["label"], "-", aim["statement"])
print(plan["significance_sentence"])
const review = JSON.parse(job.output.output);

console.log(review.proposal_title);
console.log(review.recommendation, review.overall_score, "-", review.one_line);
console.log(review.panel_summary);

for (const c of review.criteria) console.log(c.criterion, c.rating, c.score, c.note);
for (const s of review.strengths) console.log("+", s);
for (const f of review.findings) {
  console.log(`[${f.severity}] ${f.id} ${f.criterion}: ${f.what}`);
  if (f.evidence) console.log("   quoted:", f.evidence);
  console.log("   fix:", f.fix);
}

const plan = review.revision_plan;
console.log(plan.thesis);
for (const a of [...plan.actions].sort((x, y) => x.order - y.order))
  console.log(`${a.order}. [${a.effort}] ${a.target}: ${a.action}`);
for (const aim of plan.aims) console.log(aim.label, "-", aim.statement);
console.log(plan.significance_sentence);
type Criterion struct {
	Criterion string `json:"criterion"`
	Rating    string `json:"rating"` // strength | mixed | weakness
	Score     int    `json:"score"`
	Note      string `json:"note"`
}
type Finding struct {
	ID, Criterion, Severity string
	What, Evidence, Why, Fix string
}
type Review struct {
	ProposalTitle  string      `json:"proposal_title"`
	Recommendation string      `json:"recommendation"`
	OverallScore   int         `json:"overall_score"`
	OneLine        string      `json:"one_line"`
	PanelSummary   string      `json:"panel_summary"`
	Criteria       []Criterion `json:"criteria"`
	Findings       []Finding   `json:"findings"`
	Strengths      []string    `json:"strengths"`
	ComplianceCheck []struct {
		ID        string `json:"id"`
		Addressed bool   `json:"addressed"`
		Note      string `json:"note"`
	} `json:"compliance_check"`
	RevisionPlan struct {
		Thesis  string `json:"thesis"`
		Actions []struct {
			Order          int    `json:"order"`
			Target, Action string
			Effort         string `json:"effort"` // quick | substantial
		} `json:"actions"`
		Aims []struct {
			Label, Statement, Rationale, Hypothesis, Outcome string
		} `json:"aims"`
		SignificanceSentence string `json:"significance_sentence"`
	} `json:"revision_plan"`
	Summary string `json:"summary"`
}

var review Review
json.Unmarshal([]byte(job.Output.Output), &review)
// Jackson: map .data.output.output (a JSON string) onto a record tree.
record Criterion(String criterion, String rating, int score, String note) {}
record Finding(String id, String criterion, String severity,
               String what, String evidence, String why, String fix) {}
record Action(int order, String target, String action, String effort) {}
record Aim(String label, String statement, String rationale,
           String hypothesis, String outcome) {}
record RevisionPlan(String thesis, java.util.List<Action> actions,
                    java.util.List<Aim> aims, String significance_sentence) {}
record Compliance(String id, boolean addressed, String note) {}
record Review(String proposal_title, String recommendation, int overall_score,
              String one_line, String panel_summary,
              java.util.List<Criterion> criteria, java.util.List<Finding> findings,
              java.util.List<String> strengths,
              java.util.List<Compliance> compliance_check,
              RevisionPlan revision_plan, String summary) {}

Review review = mapper.readValue(outputOutputString, Review.class);
review = JSON.parse(j["output"]["output"])

puts review["proposal_title"]
puts "#{review['recommendation']} #{review['overall_score']} - #{review['one_line']}"
puts review["panel_summary"]

review["criteria"].each { |c| puts "#{c['criterion']} #{c['rating']} #{c['score']}" }
review["strengths"].each { |s| puts "+ #{s}" }
review["findings"].each do |f|
  puts "[#{f['severity']}] #{f['id']} #{f['criterion']}: #{f['what']}"
  puts "   quoted: #{f['evidence']}" unless f["evidence"].to_s.empty?
  puts "   fix: #{f['fix']}"
end

plan = review["revision_plan"]
plan["actions"].sort_by { |a| a["order"] }.each do |a|
  puts "#{a['order']}. [#{a['effort']}] #{a['target']}: #{a['action']}"
end
plan["aims"].each { |aim| puts "#{aim['label']} - #{aim['statement']}" }
$review = json_decode($j["output"]["output"], true);

echo $review["proposal_title"], "\n";
echo "{$review['recommendation']} {$review['overall_score']} - {$review['one_line']}\n";
echo $review["panel_summary"], "\n";

foreach ($review["criteria"] as $c)
  echo "{$c['criterion']} {$c['rating']} {$c['score']} {$c['note']}\n";
foreach ($review["strengths"] as $s) echo "+ $s\n";
foreach ($review["findings"] as $f) {
  echo "[{$f['severity']}] {$f['id']} {$f['criterion']}: {$f['what']}\n";
  if ($f["evidence"] !== "") echo "   quoted: {$f['evidence']}\n";
  echo "   fix: {$f['fix']}\n";
}

$plan = $review["revision_plan"];
usort($plan["actions"], fn($a, $b) => $a["order"] <=> $b["order"]);
foreach ($plan["actions"] as $a)
  echo "{$a['order']}. [{$a['effort']}] {$a['target']}: {$a['action']}\n";
foreach ($plan["aims"] as $aim) echo "{$aim['label']} - {$aim['statement']}\n";
record Criterion(string criterion, string rating, int score, string note);
record Finding(string id, string criterion, string severity,
               string what, string evidence, string why, string fix);
record Action(int order, string target, string action, string effort);
record Aim(string label, string statement, string rationale,
           string hypothesis, string outcome);
record RevisionPlan(string thesis, List<Action> actions, List<Aim> aims,
                    string significance_sentence);
record Compliance(string id, bool addressed, string note);
record Review(string proposal_title, string recommendation, int overall_score,
              string one_line, string panel_summary, List<Criterion> criteria,
              List<Finding> findings, List<string> strengths,
              List<Compliance> compliance_check, RevisionPlan revision_plan,
              string summary);

var review = JsonSerializer.Deserialize<Review>(
    job.GetProperty("output").GetProperty("output").GetString()!)!;
Console.WriteLine($"{review.recommendation} {review.overall_score} {review.one_line}");
FieldTypeMeaning
proposal_titlestringA short title for what was reviewed, inferred from the text when the applicant did not supply one.
recommendationenumcompetitive · revise · not-competitive — the disposition, not a funding decision.
overall_scoreinteger 1–9The NIH convention: 1 is exceptional and 9 is poor, so lower is better. It is applied to all four agencies — NSF and DOE do not score this way in real life, but a single scale makes drafts comparable across agencies and across revisions of the same draft. Roughly: 1–3 high impact, 4–6 medium, 7–9 low.
one_linestringThe verdict in one sentence, naming the single highest-leverage change.
panel_summarystring2–4 paragraphs in the register of a summary statement: what the project proposes, what the panel liked, what stopped it short. Paragraphs are separated by blank lines.
criteriaarrayOne entry per review criterion for the chosen agency (vocabulary below): {criterion, rating, score, note}, where rating is strength · mixed · weakness and score is on the same 1–9 scale.
findingsarray{id, criterion, severity, what, evidence, why, fix}. id is sequential and stable within one review (R-001, R-002, …); criterion ties the finding to one of the agency's criteria; severity is high · medium · low; evidence is a verbatim quote from the applicant's own text, or "" when the finding is an absence (no contingency named, no power analysis). why explains the reviewer's reasoning; fix is the concrete edit.
strengthsarray of stringWhat already works and must survive the revision. Present even for a not-competitive draft — a critique with nothing on this list is usually a critique that did not read carefully.
compliance_checkarray{id, addressed, note} — one entry for every id sent in prescan_facts.flags, confirmed (addressed: true) or explicitly overruled with the reason in note. Empty when no flags were sent.
revision_planobjectthesis, the one idea the rewrite should turn on; actions[] as {order, target, action, effort} with effort either quick or substantial; aims[] as {label, statement, rationale, hypothesis, outcome} — rewritten aims you can paste into the draft; and significance_sentence, a single sentence stating why the work matters.
summarystringA closing paragraph ready to paste into an email to a co-investigator.

The criterion vocabulary depends on the agency

criteria[].criterion is drawn from the review criteria the chosen agency actually publishes, so a critique reads the way that agency's panels read:

agencycriteria[].criterion values
nsfintellectual_merit · broader_impacts · qualifications · resources · prior_support
nihsignificance · investigators · innovation · approach · environment
doetechnical_merit · approach · personnel_facilities · budget_reasonableness · mission_relevance
darpatechnical_merit · mission_contribution · program_relevance · transition_plan · team_qualifications · cost_realism

Two invariants worth re-checking in your own code if you surface the verdict anywhere that matters. Evidence is quoted, not paraphrased: a non-empty findings[].evidence should be findable verbatim in the text you sent — a substring check is a cheap guard against a critique arguing with a proposal you did not submit. Flags are all accounted for: the set of compliance_check[].id should equal the set of prescan_facts.flags[].id; anything missing means the reply drifted and is worth re-running rather than shipping.

Step 5 — Stream the review as it is written

POST /run-stream

Same body and the same Idempotency-Key discipline as /run, but the response is a Server-Sent Events stream: delta events carry output text as it is generated, and the final job event carries the terminal job object (including charged_credits and, on failure, error). Concatenate the delta text and you have the same JSON string that output.output would have held. The app drives its progress panel off the top-level JSON keys appearing in the accumulated text — "recommendation", "criteria", "findings", "revision_plan", "summary" — which is an easy trick to replicate for a live console display: the reviewer is visibly working through the criteria before it gets to the plan.

# -N disables buffering so events print as they arrive
curl -sN -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$BODY"
# event: delta   data: {"text":"{\n  \"proposal_title\""}
# …
# event: job     data: {"status":"succeeded","charged_credits":…}
import json, requests

with requests.post(API + "/run-stream", json=payload, stream=True, timeout=600,
    headers={"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}) as r:
    event, raw = None, []
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            data = json.loads(line[5:])
            if event == "delta":
                raw.append(data.get("text", ""))
            elif event == "job" and data["status"] == "succeeded":
                review = json.loads("".join(raw) or data["output"]["output"])
                print(review["recommendation"], review["overall_score"],
                      "-", review["one_line"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
    "Idempotency-Key": key },
  body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  for (const line of buf.split("\n")) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const data = JSON.parse(line.slice(5));
      if (event === "delta") raw += data.text ?? "";
      else if (event === "job" && data.status === "succeeded")
        console.log(JSON.parse(raw || data.output.output).recommendation);
    }
  }
  buf = buf.slice(buf.lastIndexOf("\n") + 1);
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
var event string
var raw strings.Builder
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(line[5:]), &data)
		if event == "delta" {
			if t, ok := data["text"].(string); ok {
				raw.WriteString(t)
			}
		}
	}
}
var review Review
json.Unmarshal([]byte(raw.String()), &review)
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(payloadJson))
    .build();
var res = http.send(req, HttpResponse.BodyHandlers.ofLines());
var raw = new StringBuilder();
String[] event = {null};
res.body().forEach(line -> {
  if (line.startsWith("event:")) event[0] = line.substring(6).trim();
  else if (line.startsWith("data:") && "delta".equals(event[0]))
    raw.append(parseTextField(line.substring(5))); // your JSON library
});
Review review = mapper.readValue(raw.toString(), Review.class);
require "net/http"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = payload.to_json

raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 600) do |h|
  h.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:") && event == "delta"
          raw << (JSON.parse(line[5..])["text"] || "")
        end
      end
    end
  end
end
review = JSON.parse(raw)
puts "#{review['recommendation']} #{review['overall_score']}"
$event = null;
$raw = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_POSTFIELDS => json_encode($payload),
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer $TOKEN",
    "Content-Type: application/json",
    "Idempotency-Key: $key",
  ],
  CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$raw) {
    foreach (explode("\n", $chunk) as $line) {
      if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
      elseif (str_starts_with($line, "data:") && $event === "delta") {
        $data = json_decode(substr($line, 5), true);
        $raw .= $data["text"] ?? "";
      }
    }
    return strlen($chunk);
  },
]);
curl_exec($ch);
curl_close($ch);
$review = json_decode($raw, true);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream") {
  Content = JsonContent.Create(payload)
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", key);
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var stream = new StreamReader(await res.Content.ReadAsStreamAsync());
string? line, ev = null;
var raw = new StringBuilder();
while ((line = await stream.ReadLineAsync()) is not null) {
  if (line.StartsWith("event:")) ev = line[6..].Trim();
  else if (line.StartsWith("data:") && ev == "delta") {
    var data = JsonDocument.Parse(line[5..]).RootElement;
    if (data.TryGetProperty("text", out var t)) raw.Append(t.GetString());
  }
}
var review = JsonDocument.Parse(raw.ToString()).RootElement;

Streaming and polling bill identically — the stream is a delivery choice, not a pricing one. Reuse the key from step 4 and a stream that dropped halfway can be re-requested without paying twice.

Step 6 — Store and search the review history

POST /collections/reviews/query POST /collections/reviews/records

The app declares one collection, reviews, and writes a record after every successful run so a draft can be compared against its own earlier passes. Records are scoped to the calling subject — nobody else can read yours, and each POST /guest mints a new identity, so reuse a single token across writes and reads. Indexed (filterable) fields: proposal_title, agency, mechanism, section, field, recommendation, overall_score, high_findings, flags_total, flags_addressed, words, ran_at. Embedded (vector-searchable) fields: proposal_title and summary. Anything else you attach — the whole parsed review, for instance — rides along as an undeclared key: stored and returned intact, just not filterable.

Do not store the proposal text. A narrative does not fit the 64 KB per-document cap, and it is the applicant's unpublished work; the record keeps the verdict and the measurements, not the draft.

The paths. Query is POST /collections/reviews/query; record CRUD sits under /records and wraps the document in a doc envelope — POST /collections/reviews/records with {"doc": {…}} returns {"data":{"record":{"record_id":"rec_…"}}}, and GET · PUT · DELETE /collections/reviews/records/{record_id} read, replace and remove one. Every where entry must be an operator object ({"eq": …}); a bare value is rejected. Operators: eq ne lt lte gt gte in contains. Sort with {"field": …, "dir": "asc"|"desc"}. Semantic search is POST /collections/reviews/similar with {"text": "the battery proposal the panel called incremental", "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. Results nest the document under doc: read rec.doc.recommendation, never rec.recommendation.
# Write a record after a successful run.
curl -s -X POST "$API/collections/reviews/records" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"doc": {
        "proposal_title": "Dendritic gating in cortical circuits",
        "agency": "nih", "mechanism": "R01", "section": "aims",
        "field": "computational neuroscience",
        "recommendation": "revise", "overall_score": 4,
        "high_findings": 2, "flags_total": 1, "flags_addressed": 1,
        "words": 612, "ran_at": "2026-08-12T10:24:00Z",
        "summary": "Strong significance, approach under-specified.",
        "review": {}
      }}' | jq '.data.record.record_id'

# Every NIH aims page that is not yet competitive, worst score first.
curl -s -X POST "$API/collections/reviews/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"where":{"agency":{"eq":"nih"},"recommendation":{"ne":"competitive"}},
       "sort":{"field":"overall_score","dir":"desc"},"limit":20}' | jq '.data.records[].doc'

# Semantic search over proposal_title + summary (30/min per IP):
curl -s -X POST "$API/collections/reviews/similar" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"text":"the battery proposal the panel called incremental","limit":8}'
import datetime

doc = {
    "proposal_title": review["proposal_title"],
    "agency": payload["agency"], "mechanism": payload["mechanism"],
    "section": payload["section"], "field": payload["field"],
    "recommendation": review["recommendation"],
    "overall_score": review["overall_score"],
    "high_findings": sum(1 for f in review["findings"] if f["severity"] == "high"),
    "flags_total": len(review["compliance_check"]),
    "flags_addressed": sum(1 for c in review["compliance_check"] if c["addressed"]),
    "words": prescan["stats"]["words"],
    "ran_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    "summary": review["summary"],
    "review": review,            # undeclared: stored whole, not filterable
}
rec = api("POST", "/collections/reviews/records", {"doc": doc})["record"]
print("stored", rec["record_id"])

res = api("POST", "/collections/reviews/query", {
    "where": {"agency": {"eq": "nih"}, "recommendation": {"ne": "competitive"}},
    "sort": {"field": "overall_score", "dir": "desc"},
    "limit": 20,
})
for r in res["records"]:
    d = r["doc"]                 # records nest under "doc" — always unwrap
    print(d["ran_at"], d["overall_score"], d["recommendation"], d["proposal_title"])

hits = api("POST", "/collections/reviews/similar",
           {"text": "the battery proposal the panel called incremental", "limit": 8})
for r in hits["records"]:
    print(round(r.get("score", 0), 2), r["doc"]["proposal_title"])
const doc = {
  proposal_title: review.proposal_title,
  agency: payload.agency, mechanism: payload.mechanism,
  section: payload.section, field: payload.field,
  recommendation: review.recommendation,
  overall_score: review.overall_score,
  high_findings: review.findings.filter(f => f.severity === "high").length,
  flags_total: review.compliance_check.length,
  flags_addressed: review.compliance_check.filter(c => c.addressed).length,
  words: prescan.stats.words,
  ran_at: new Date().toISOString(),
  summary: review.summary,
  review,                        // undeclared: stored whole, not filterable
};
const { record } = await api("POST", "/collections/reviews/records", { doc });
console.log("stored", record.record_id);

const res = await api("POST", "/collections/reviews/query", {
  where: { agency: { eq: "nih" }, recommendation: { ne: "competitive" } },
  sort: { field: "overall_score", dir: "desc" },
  limit: 20,
});
for (const r of res.records) {
  const d = r.doc;               // records nest under "doc" — always unwrap
  console.log(d.ran_at, d.overall_score, d.recommendation, d.proposal_title);
}

const hits = await api("POST", "/collections/reviews/similar",
  { text: "the battery proposal the panel called incremental", limit: 8 });
for (const r of hits.records) console.log(r.score, r.doc.proposal_title);
// Write: POST /collections/reviews/records with {"doc": {…}}
doc := map[string]any{
	"proposal_title": review.ProposalTitle,
	"agency":         "nih",
	"mechanism":      "R01",
	"section":        "aims",
	"recommendation": review.Recommendation,
	"overall_score":  review.OverallScore,
	"ran_at":         time.Now().UTC().Format(time.RFC3339),
	"summary":        review.Summary,
}
var created struct {
	Record struct {
		RecordID string `json:"record_id"`
	} `json:"record"`
}
if err := api("POST", "/collections/reviews/records",
	map[string]any{"doc": doc}, &created); err != nil {
	panic(err)
}

// Query: every where entry is an operator object.
query := map[string]any{
	"where": map[string]any{
		"agency":         map[string]any{"eq": "nih"},
		"recommendation": map[string]any{"ne": "competitive"},
	},
	"sort":  map[string]string{"field": "overall_score", "dir": "desc"},
	"limit": 20,
}
var res struct {
	Records []struct {
		RecordID string         `json:"record_id"`
		Doc      map[string]any `json:"doc"`
	} `json:"records"`
}
if err := api("POST", "/collections/reviews/query", query, &res); err != nil {
	panic(err)
}
for _, r := range res.Records {
	fmt.Println(r.Doc["ran_at"], r.Doc["overall_score"], r.Doc["proposal_title"])
}
// Write — POST /collections/reviews/records, document wrapped in "doc":
String create = """
  {"doc": {"proposal_title": "Dendritic gating in cortical circuits",
           "agency": "nih", "mechanism": "R01", "section": "aims",
           "recommendation": "revise", "overall_score": 4,
           "ran_at": "2026-08-12T10:24:00Z",
           "summary": "Strong significance, approach under-specified."}}
  """;
String created = api("POST", "/collections/reviews/records", create);
// -> {"data":{"record":{"record_id":"rec_…"}}}

// Query — every where entry is an operator object:
String q = "{\"where\":{\"agency\":{\"eq\":\"nih\"}," +
           "\"recommendation\":{\"ne\":\"competitive\"}}," +
           "\"sort\":{\"field\":\"overall_score\",\"dir\":\"desc\"},\"limit\":20}";
System.out.println(api("POST", "/collections/reviews/query", q));
// Each record nests the document under "doc".
// Semantic search: POST /collections/reviews/similar {"text":"…","limit":8}
doc = {
  "proposal_title" => review["proposal_title"],
  "agency" => "nih", "mechanism" => "R01", "section" => "aims",
  "recommendation" => review["recommendation"],
  "overall_score" => review["overall_score"],
  "high_findings" => review["findings"].count { |f| f["severity"] == "high" },
  "ran_at" => Time.now.utc.iso8601,
  "summary" => review["summary"],
  "review" => review,
}
rec = api("POST", "/collections/reviews/records", { "doc" => doc })["record"]
puts "stored #{rec['record_id']}"

res = api("POST", "/collections/reviews/query", {
  "where" => { "agency" => { "eq" => "nih" },
               "recommendation" => { "ne" => "competitive" } },
  "sort" => { "field" => "overall_score", "dir" => "desc" },
  "limit" => 20,
})
res["records"].each do |r|
  d = r["doc"]   # records nest under "doc"
  puts "#{d['ran_at']} #{d['overall_score']} #{d['proposal_title']}"
end
$doc = [
  "proposal_title" => $review["proposal_title"],
  "agency" => "nih", "mechanism" => "R01", "section" => "aims",
  "recommendation" => $review["recommendation"],
  "overall_score" => $review["overall_score"],
  "ran_at" => gmdate("c"),
  "summary" => $review["summary"],
  "review" => $review,
];
$rec = api("POST", "/collections/reviews/records", ["doc" => $doc])["record"];
echo "stored {$rec['record_id']}\n";

$res = api("POST", "/collections/reviews/query", [
  "where" => ["agency" => ["eq" => "nih"],
              "recommendation" => ["ne" => "competitive"]],
  "sort" => ["field" => "overall_score", "dir" => "desc"],
  "limit" => 20,
]);
foreach ($res["records"] as $r) {
  $d = $r["doc"];   // records nest under "doc"
  echo "{$d['ran_at']} {$d['overall_score']} {$d['proposal_title']}\n";
}
var doc = new {
  proposal_title = review.proposal_title,
  agency = "nih", mechanism = "R01", section = "aims",
  recommendation = review.recommendation,
  overall_score = review.overall_score,
  high_findings = review.findings.Count(f => f.severity == "high"),
  ran_at = DateTime.UtcNow.ToString("o"),
  summary = review.summary,
};
var created = await SkillSafe.ApiAsync(HttpMethod.Post,
    "/collections/reviews/records", new { doc });
Console.WriteLine(created.GetProperty("record").GetProperty("record_id"));

var q = new {
  where = new { agency = new { eq = "nih" },
                recommendation = new { ne = "competitive" } },
  sort = new { field = "overall_score", dir = "desc" },
  limit = 20
};
var res = await SkillSafe.ApiAsync(HttpMethod.Post, "/collections/reviews/query", q);
foreach (var r in res.GetProperty("records").EnumerateArray()) {
  var d = r.GetProperty("doc");   // records nest under "doc"
  Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("proposal_title")}");
}

Indexing for /similar is asynchronous, and only records written after the collection was declared are searchable — there is no backfill. Query results paginate: the envelope's meta.pagination carries has_more and next_cursor, which you pass back as cursor on the next query.

Putting it together

The loop this app was built for is short. Scan the section locally, estimate, run, read revision_plan, edit the draft, and run again with the previous critique in prior_critique — then compare the two overall_score values in the reviews collection. A draft that moves from 6 to 4 across two passes has learned something; a draft that does not move has usually been edited for prose rather than for the objection.

Three things to hold on to while you build on this. The panel is simulated: it is calibrated against published review criteria, and its score is a rehearsal, not a prediction — nothing here is a decision by NSF, NIH, DOE or DARPA, and no real panel is bound by it. The evidence is the point: a finding that quotes your own sentence back at you is checkable, and the ones with an empty evidence are claims about what is missing, which deserve a human's judgement before you rewrite. The draft stays yours: the run reads your text, the stored record does not keep it, and what you paste into a proposal is your call and your responsibility to verify.