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.
| Status | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing, malformed or expired token — mint a new one (step 1) and retry once. Guest tokens expire; personal tokens are revoked from the token page. |
402 | payment_required | Not 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. |
404 | not_found | Unknown job id, unknown record id, or one belonging to another subject. Jobs and collection records are readable only by the subject that created them. |
409 | conflict | An 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. |
422 | validation_error | The 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. |
429 | rate_limited | Too 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. |
5xx | — | Transient 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:
| Field | Type | Meaning |
|---|---|---|
model | string | The exact model version this app is pinned to for the run. |
model_alias | string | The 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_bps | integer | The app's markup over raw inference cost, in basis points (0 means at cost, 1000 means +10%). |
hold_credits | integer | The 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_credits | integer | The floor below which the run will not start. If credits from /me is under this, you get a 402. |
sponsor_enabled | boolean | Whether 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.
| Field | Type | Meaning |
|---|---|---|
agency | enum, optional | nsf · 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. |
mechanism | string, optional | Free 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. |
section | enum, optional | Which 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. |
field | string, optional | The 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. |
text | string, required | The 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_critique | string, optional | Reviewer 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_facts | object, optional | What 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" } },
},
};
| Stat | Type | Meaning |
|---|---|---|
words · chars | integer | Length of text, counted client-side. |
est_pages | number | Estimated printed pages at agency formatting — the number the panel reasons about when it says a section is over-packed. |
sentences · avg_sentence_words | integer · number | Sentence count and mean length. |
long_sentences | integer | Sentences past the readability threshold. A reviewer reading forty of these at a sitting notices. |
passive_ratio | number 0–1 | Fraction of sentences in the passive voice. |
acronyms · undefined_acronyms | integer | Distinct acronyms used, and how many are never expanded on first use. The second number is the one that costs you. |
aims | integer | Numbered aims or objectives detected. |
citations · figures | integer | Citation markers and figure references found in the text. |
limit_words · over_limit | integer · boolean | The 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}");
| Field | Type | Meaning |
|---|---|---|
proposal_title | string | A short title for what was reviewed, inferred from the text when the applicant did not supply one. |
recommendation | enum | competitive · revise · not-competitive — the disposition, not a funding decision. |
overall_score | integer 1–9 | The 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_line | string | The verdict in one sentence, naming the single highest-leverage change. |
panel_summary | string | 2–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. |
criteria | array | One 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. |
findings | array | {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. |
strengths | array of string | What 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_check | array | {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_plan | object | thesis, 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. |
summary | string | A 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:
| agency | criteria[].criterion values |
|---|---|
nsf | intellectual_merit · broader_impacts · qualifications · resources · prior_support |
nih | significance · investigators · innovation · approach · environment |
doe | technical_merit · approach · personnel_facilities · budget_reasonableness · mission_relevance |
darpa | technical_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.
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.