Generate SR&ED write-ups from your own pipeline
Send the raw work evidence plus the work items you parsed from it, and get back one JSON
object: the items grouped into projects, each judged against the CRA two-part
test (sredable, classification, why), eligible
projects written up in the submission format (description and
goals at 100 words maximum, uncertainties each with experiments,
results and evidence links), plus unassigned items with reasons,
gaps and next_steps. The output is mechanically checkable: the
app's own sredkit.js verifies every URL and number against the input and that
every item ref lands in exactly one place — and your pipeline can do the same. Wire it
into an HR or finance workflow to draft every engineer's claim from a
gh pr list export. Every code step below is shown in cURL, Python, JavaScript,
Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows. Drafting aid,
not tax advice.
Basics
Base URL https://api.skillsafe.ai/v1/app-api, app slug
sred-desk. Send X-App-Slug: sred-desk and
Authorization: Bearer <token> on every call. Responses use one envelope:
{"ok":true,"data":...} on success, {"ok":false,"error":{"code","message","details"}}
on failure.
| code | HTTP | meaning | do |
|---|---|---|---|
unauthorized | 401 | Missing/expired token | Mint a guest token or sign in; see tokens |
payment_required | 402 | Balance below min_credits | Top up, or accept a truncated run |
forbidden | 403 | Token belongs to another app | Mint a token for sred-desk |
not_found | 404 | Bad path or job id | Check the endpoint and the job_id |
validation_error | 400 | Input shape wrong | Read error.details; match the schema below |
rate_limited | 429 | Too many calls | Back off; /similar is 30/min per IP |
server_error | 5xx | Platform hiccup | Retry with the same Idempotency-Key |
Step 1 — a token
The quickest lane is a guest token: POST /guest with the slug, no body, no
account. Guests can call /me and /estimate; paid runs need a
personal token — get one on the token page (sign in there,
then copy it; never paste tokens into shared code). Put the token in the
Authorization header as Bearer YOUR_TOKEN.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "X-App-Slug: sred-desk" -H "Content-Type: application/json" \
-d '{"slug":"sred-desk"}'
# {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
import requests
API = "https://api.skillsafe.ai/v1/app-api"
H = {"X-App-Slug": "sred-desk"}
token = requests.post(f"{API}/guest", headers=H,
json={"slug": "sred-desk"}).json()["data"]["token"]
H["Authorization"] = f"Bearer {token}" # or a personal token from /tokens.html
const API = "https://api.skillsafe.ai/v1/app-api";
const H = { "X-App-Slug": "sred-desk", "Content-Type": "application/json" };
const { data } = await (await fetch(`${API}/guest`, {
method: "POST", headers: H, body: JSON.stringify({ slug: "sred-desk" }),
})).json();
H.Authorization = `Bearer ${data.token}`; // or a personal token from /tokens.html
api := "https://api.skillsafe.ai/v1/app-api"
req, _ := http.NewRequest("POST", api+"/guest", strings.NewReader(`{"slug":"sred-desk"}`))
req.Header.Set("X-App-Slug", "sred-desk")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
var out struct{ Data struct{ Token string } }
json.NewDecoder(resp.Body).Decode(&out)
token := out.Data.Token // or a personal token from /tokens.html
var api = "https://api.skillsafe.ai/v1/app-api";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create(api + "/guest"))
.header("X-App-Slug", "sred-desk")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"sred-desk\"}")).build();
var body = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
// parse body.data.token with your JSON library; then send it as Bearer YOUR_TOKEN
require "net/http"; require "json"
API = "https://api.skillsafe.ai/v1/app-api"
uri = URI("#{API}/guest")
req = Net::HTTP::Post.new(uri, "X-App-Slug" => "sred-desk",
"Content-Type" => "application/json")
req.body = { slug: "sred-desk" }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body).dig("data", "token")
$api = "https://api.skillsafe.ai/v1/app-api";
$ch = curl_init("$api/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-App-Slug: sred-desk", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "sred-desk"])]);
$token = json_decode(curl_exec($ch), true)["data"]["token"];
var api = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-App-Slug", "sred-desk");
var guest = await http.PostAsJsonAsync($"{api}/guest", new { slug = "sred-desk" });
var token = JsonDocument.Parse(await guest.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("token").GetString();
http.DefaultRequestHeaders.Authorization = new("Bearer", token);
Step 2 — who am I, and can I afford a run
GET /me is free and returns subject_type
(guest or user) and credits. Compare the balance
against the estimate's hold_credits before submitting — the app itself
refuses to enable its run button on a short balance, and a polite client does the same.
curl -s https://api.skillsafe.ai/v1/app-api/me \ -H "X-App-Slug: sred-desk" -H "Authorization: Bearer YOUR_TOKEN"
me = requests.get(f"{API}/me", headers=H).json()["data"]
print(me["subject_type"], me["credits"])
const me = (await (await fetch(`${API}/me`, { headers: H })).json()).data;
console.log(me.subject_type, me.credits);
req, _ = http.NewRequest("GET", api+"/me", nil)
req.Header.Set("X-App-Slug", "sred-desk")
req.Header.Set("Authorization", "Bearer "+token)
resp, _ = http.DefaultClient.Do(req) // decode data.subject_type, data.credits
var meReq = HttpRequest.newBuilder(URI.create(api + "/me"))
.header("X-App-Slug", "sred-desk")
.header("Authorization", "Bearer " + token).GET().build();
System.out.println(client.send(meReq, HttpResponse.BodyHandlers.ofString()).body());
uri = URI("#{API}/me")
req = Net::HTTP::Get.new(uri, "X-App-Slug" => "sred-desk",
"Authorization" => "Bearer #{TOKEN}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
$ch = curl_init("$api/me");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [
"X-App-Slug: sred-desk", "Authorization: Bearer $token"]]);
print_r(json_decode(curl_exec($ch), true)["data"]);
var meJson = await http.GetStringAsync($"{api}/me");
Console.WriteLine(meJson); // data.subject_type, data.credits
Step 3 — the input, and what it costs
POST /estimate is free, charges nothing, starts no job. It validates the input
and returns model, hold_credits (the reserve, an upper bound
— not the price) and min_credits. The input object is the same for
/estimate, /run and /run-stream. Parse your evidence
into items the way the app does (one item per line carrying a URL, a date, a
ticket key or a bullet; stable refs w1..wN) — sredkit.js in
this bundle is the reference parser.
| field | type | notes |
|---|---|---|
subject | string | whose work this is; may be empty |
claim_year | string | e.g. 2026; window Feb 1 prev year to Jan 31 |
company | string | optional product/company context |
focus | string | optional emphasis |
include_incidents | bool | false keeps INC- items out of eligible projects |
work_text | string | the raw paste; clip long text from the middle, never the tail |
work_clipped | bool | true when work_text is an excerpt |
items | array | [{ref, line, title, url, date, kind, incident}] — kind is pr|ticket|doc|note; refs are authoritative for coverage |
prescan | object | {item_count, pr_count, ticket_count, doc_count, note_count, url_count, undated_count, incident_count, out_of_window_count, window} |
current_datetime | string | caller-local timestamp with offset and weekday |
cat > input.json <<'EOF'
{"subject":"Noah Tran","claim_year":"2026","company":"Relay, a log analytics platform",
"focus":"","include_incidents":false,
"work_text":"- Adaptive trace sampling: bias correction for burst traffic https://github.com/relayhq/relay-db/pull/2481 (merged 2025-04-18)\n- REL-1203 Investigate why tail-based sampling under-counts rare errors (closed 2025-04-10)",
"work_clipped":false,
"items":[{"ref":"w1","line":1,"title":"Adaptive trace sampling: bias correction for burst traffic","url":"https://github.com/relayhq/relay-db/pull/2481","date":"2025-04-18","kind":"pr","incident":false},
{"ref":"w2","line":2,"title":"REL-1203 Investigate why tail-based sampling under-counts rare errors","url":"","date":"2025-04-10","kind":"ticket","incident":false}],
"prescan":{"item_count":2,"pr_count":1,"ticket_count":1,"doc_count":0,"note_count":0,"url_count":1,"undated_count":0,"incident_count":0,"out_of_window_count":0,"window":{"from":"2025-02-01","to":"2026-01-31"}},
"current_datetime":"2026-08-10T09:00:00+08:00 (Monday)"}
EOF
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "X-App-Slug: sred-desk" -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"input\": $(cat input.json)}"
items = [
{"ref": "w1", "line": 1, "title": "Adaptive trace sampling: bias correction for burst traffic",
"url": "https://github.com/relayhq/relay-db/pull/2481", "date": "2025-04-18", "kind": "pr", "incident": False},
{"ref": "w2", "line": 2, "title": "REL-1203 Investigate why tail-based sampling under-counts rare errors",
"url": "", "date": "2025-04-10", "kind": "ticket", "incident": False},
]
inp = {
"subject": "Noah Tran", "claim_year": "2026",
"company": "Relay, a log analytics platform", "focus": "",
"include_incidents": False,
"work_text": "\n".join(f"- {i['title']} {i['url']} ({i['date']})" for i in items),
"work_clipped": False, "items": items,
"prescan": {"item_count": 2, "pr_count": 1, "ticket_count": 1, "doc_count": 0,
"note_count": 0, "url_count": 1, "undated_count": 0, "incident_count": 0,
"out_of_window_count": 0, "window": {"from": "2025-02-01", "to": "2026-01-31"}},
"current_datetime": "2026-08-10T09:00:00+08:00 (Monday)",
}
est = requests.post(f"{API}/estimate", headers=H, json={"input": inp}).json()["data"]
print(est["model"], est["hold_credits"], est["min_credits"])
const items = [
{ ref: "w1", line: 1, title: "Adaptive trace sampling: bias correction for burst traffic",
url: "https://github.com/relayhq/relay-db/pull/2481", date: "2025-04-18", kind: "pr", incident: false },
{ ref: "w2", line: 2, title: "REL-1203 Investigate why tail-based sampling under-counts rare errors",
url: "", date: "2025-04-10", kind: "ticket", incident: false },
];
const input = {
subject: "Noah Tran", claim_year: "2026",
company: "Relay, a log analytics platform", focus: "",
include_incidents: false,
work_text: items.map(i => `- ${i.title} ${i.url} (${i.date})`).join("\n"),
work_clipped: false, items,
prescan: { item_count: 2, pr_count: 1, ticket_count: 1, doc_count: 0, note_count: 0,
url_count: 1, undated_count: 0, incident_count: 0, out_of_window_count: 0,
window: { from: "2025-02-01", to: "2026-01-31" } },
current_datetime: new Date().toString(),
};
const est = (await (await fetch(`${API}/estimate`, {
method: "POST", headers: H, body: JSON.stringify({ input }),
})).json()).data;
console.log(est.model, est.hold_credits);
payload := map[string]any{"input": input} // build input as a map matching the table above
b, _ := json.Marshal(payload)
req, _ = http.NewRequest("POST", api+"/estimate", bytes.NewReader(b))
req.Header.Set("X-App-Slug", "sred-desk")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ = http.DefaultClient.Do(req) // decode data.hold_credits, data.model
String input = "{\"subject\":\"Noah Tran\",\"claim_year\":\"2026\",...}"; // as the table above
var estReq = HttpRequest.newBuilder(URI.create(api + "/estimate"))
.header("X-App-Slug", "sred-desk")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"input\":" + input + "}")).build();
System.out.println(client.send(estReq, HttpResponse.BodyHandlers.ofString()).body());
input = { subject: "Noah Tran", claim_year: "2026",
company: "Relay, a log analytics platform", focus: "",
include_incidents: false, work_text: "- Adaptive trace sampling ...",
work_clipped: false, items: [ { ref: "w1", line: 1, title: "Adaptive trace sampling",
url: "https://github.com/relayhq/relay-db/pull/2481", date: "2025-04-18",
kind: "pr", incident: false } ],
prescan: { item_count: 1, pr_count: 1, ticket_count: 0, doc_count: 0, note_count: 0,
url_count: 1, undated_count: 0, incident_count: 0, out_of_window_count: 0,
window: { from: "2025-02-01", to: "2026-01-31" } },
current_datetime: Time.now.to_s }
uri = URI("#{API}/estimate")
req = Net::HTTP::Post.new(uri, "X-App-Slug" => "sred-desk",
"Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json")
req.body = { input: input }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
$items = [["ref" => "w1", "line" => 1, "title" => "Adaptive trace sampling",
"url" => "https://github.com/relayhq/relay-db/pull/2481", "date" => "2025-04-18",
"kind" => "pr", "incident" => false]];
$input = ["subject" => "Noah Tran", "claim_year" => "2026",
"company" => "Relay, a log analytics platform", "focus" => "",
"include_incidents" => false, "work_text" => "- Adaptive trace sampling ...",
"work_clipped" => false, "items" => $items,
"prescan" => ["item_count" => 1, "pr_count" => 1, "ticket_count" => 0,
"doc_count" => 0, "note_count" => 0, "url_count" => 1, "undated_count" => 0,
"incident_count" => 0, "out_of_window_count" => 0,
"window" => ["from" => "2025-02-01", "to" => "2026-01-31"]],
"current_datetime" => date("c")];
$ch = curl_init("$api/estimate");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-App-Slug: sred-desk", "Authorization: Bearer $token",
"Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["input" => $input])]);
print_r(json_decode(curl_exec($ch), true)["data"]);
var items = new[] { new { @ref = "w1", line = 1, title = "Adaptive trace sampling",
url = "https://github.com/relayhq/relay-db/pull/2481", date = "2025-04-18",
kind = "pr", incident = false } };
var input = new { subject = "Noah Tran", claim_year = "2026",
company = "Relay, a log analytics platform", focus = "",
include_incidents = false, work_text = "- Adaptive trace sampling ...",
work_clipped = false, items,
prescan = new { item_count = 1, pr_count = 1, ticket_count = 0, doc_count = 0,
note_count = 0, url_count = 1, undated_count = 0, incident_count = 0,
out_of_window_count = 0, window = new { from = "2025-02-01", to = "2026-01-31" } },
current_datetime = DateTimeOffset.Now.ToString() };
var est = await http.PostAsJsonAsync($"{api}/estimate", new { input });
Console.WriteLine(await est.Content.ReadAsStringAsync());
hold_credits prices the full output cap and is almost always far above the
settled charge. If the balance sits between min_credits and
hold_credits the run still executes with a reduced cap and the job carries
"truncated": true — treat that as “cut short, top up for the whole
summary”, not as a complete answer.
Step 4 — run and poll
POST /run starts a billed job and returns a job_id; poll
GET /runs/{id} until status is terminal. Always send an
Idempotency-Key header derived from the input — a network retry with the
same key can never double-bill.
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "X-App-Slug: sred-desk" -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sd-$(shasum <<< "$(cat input.json)" | cut -c1-16)" \
-d "{\"input\": $(cat input.json)}" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['job_id'])")
curl -s "https://api.skillsafe.ai/v1/app-api/runs/$JOB" \
-H "X-App-Slug: sred-desk" -H "Authorization: Bearer YOUR_TOKEN"
import hashlib, json as jsonlib, time
key = "sd-" + hashlib.sha256(jsonlib.dumps(inp, sort_keys=True).encode()).hexdigest()[:16]
job = requests.post(f"{API}/run", headers={**H, "Idempotency-Key": key},
json={"input": inp}).json()["data"]["job_id"]
while True:
j = requests.get(f"{API}/runs/{job}", headers=H).json()["data"]
if j["status"] in ("succeeded", "failed", "canceled"): break
time.sleep(2)
doc = jsonlib.loads(j["output"]["output"]) # the summary object
print(doc["title"], len(doc["projects"]), "projects")
const key = "sd-" + [...new Uint8Array(await crypto.subtle.digest("SHA-256",
new TextEncoder().encode(JSON.stringify(input))))].slice(0, 8)
.map(b => b.toString(16).padStart(2, "0")).join("");
const { data: run } = await (await fetch(`${API}/run`, {
method: "POST", headers: { ...H, "Idempotency-Key": key },
body: JSON.stringify({ input }),
})).json();
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = (await (await fetch(`${API}/runs/${run.job_id}`, { headers: H })).json()).data;
} while (!["succeeded", "failed", "canceled"].includes(job.status));
const doc = JSON.parse(job.output.output);
req, _ = http.NewRequest("POST", api+"/run", bytes.NewReader(b))
req.Header.Set("X-App-Slug", "sred-desk")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "sd-"+inputHash) // any stable hash of the input
resp, _ = http.DefaultClient.Do(req)
// decode data.job_id, then GET api+"/runs/"+jobID every 2s until terminal;
// the summary is json.Unmarshal(data.output.output)
var runReq = HttpRequest.newBuilder(URI.create(api + "/run"))
.header("X-App-Slug", "sred-desk")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "sd-" + Integer.toHexString(input.hashCode()))
.POST(HttpRequest.BodyPublishers.ofString("{\"input\":" + input + "}")).build();
// read data.job_id; poll api + "/runs/" + jobId every 2s until status is terminal
require "digest"
key = "sd-" + Digest::SHA256.hexdigest(input.to_json)[0, 16]
uri = URI("#{API}/run")
req = Net::HTTP::Post.new(uri, "X-App-Slug" => "sred-desk",
"Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key)
req.body = { input: input }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body).dig("data", "job_id")
# GET "#{API}/runs/#{job_id}" every 2s until status is terminal
$key = "sd-" . substr(hash("sha256", json_encode($input)), 0, 16);
$ch = curl_init("$api/run");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-App-Slug: sred-desk", "Authorization: Bearer $token",
"Content-Type: application/json", "Idempotency-Key: $key"],
CURLOPT_POSTFIELDS => json_encode(["input" => $input])]);
$job = json_decode(curl_exec($ch), true)["data"]["job_id"];
// GET "$api/runs/$job" every 2s until status is terminal
var key = "sd-" + Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input))))[..16];
var msg = new HttpRequestMessage(HttpMethod.Post, $"{api}/run")
{ Content = JsonContent.Create(new { input }) };
msg.Headers.Add("Idempotency-Key", key);
var run = await http.SendAsync(msg);
// read data.job_id; poll $"{api}/runs/{jobId}" every 2s until terminal
Step 5 — stream instead of polling
POST /run-stream is the same call with an SSE response: job events
carry the id, delta events carry output text as it is written, and a final
done event carries the terminal job including charged_credits.
The app itself uses this lane to advance its progress stages as output keys appear in the
stream.
curl -sN -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "X-App-Slug: sred-desk" -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" -H "Accept: text/event-stream" \
-H "Idempotency-Key: sd-stream-1" \
-d "{\"input\": $(cat input.json)}"
with requests.post(f"{API}/run-stream", headers={**H, "Accept": "text/event-stream",
"Idempotency-Key": key + "-s"}, json={"input": inp}, stream=True) as r:
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data:"):
print(line[5:].strip())
const res = await fetch(`${API}/run-stream`, {
method: "POST",
headers: { ...H, Accept: "text/event-stream", "Idempotency-Key": key + "-s" },
body: JSON.stringify({ input }),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(dec.decode(value));
}
req.Header.Set("Accept", "text/event-stream")
resp, _ = http.DefaultClient.Do(req)
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "data:") { fmt.Println(strings.TrimSpace(line[5:])) }
}
// Send the same POST with Accept: text/event-stream and read the body line by
// line: lines starting with "data:" carry the JSON events (job, delta, done).
var streamReq = HttpRequest.newBuilder(URI.create(api + "/run-stream"))
.header("X-App-Slug", "sred-desk")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString("{\"input\":" + input + "}")).build();
client.send(streamReq, HttpResponse.BodyHandlers.ofLines())
.body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
uri = URI("#{API}/run-stream")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
req = Net::HTTP::Post.new(uri, "X-App-Slug" => "sred-desk",
"Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Accept" => "text/event-stream")
req.body = { input: input }.to_json
h.request(req) { |res| res.read_body { |chunk| print chunk } }
end
$ch = curl_init("$api/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["X-App-Slug: sred-desk", "Authorization: Bearer $token",
"Content-Type: application/json", "Accept: text/event-stream"],
CURLOPT_POSTFIELDS => json_encode(["input" => $input]),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { echo $chunk; return strlen($chunk); }]);
curl_exec($ch);
var sMsg = new HttpRequestMessage(HttpMethod.Post, $"{api}/run-stream")
{ Content = JsonContent.Create(new { input }) };
sMsg.Headers.Accept.Add(new("text/event-stream"));
var stream = await http.SendAsync(sMsg, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await stream.Content.ReadAsStreamAsync());
while (await sr.ReadLineAsync() is { } line)
if (line.StartsWith("data:")) Console.WriteLine(line[5..]);
The output contract
The job's output.output is one JSON object:
{title, subject, claim_year, summary, projects, unassigned, gaps, next_steps}.
Each project carries id, name, sredable,
classification (basic research | applied research | experimental
development | not eligible), why, description and
goals (non-empty and at most 100 words each when eligible, empty strings
otherwise), uncertainties
([{title, description, experiments[], results[], links[]}], at least one per
eligible project, empty for ineligible ones) and item_refs.
unassigned is [{ref, reason}].
The contract's teeth are mechanical: every input item ref appears in exactly one project's
item_refs or in unassigned; every URL and number in the output
exists somewhere in the input; when include_incidents is false no
incident-flagged ref sits inside an eligible project. The app re-runs these checks
client-side after every run and prints failures; a pipeline consuming this API should do
the same before letting a write-up anywhere near a claim — sredkit.js in
this bundle is the reference implementation.
Step 6 — history: the summaries collection
Signed-in write-ups are stored in the app's summaries collection
(per-user read/write). List them, filter with the query DSL, or search semantically —
/similar is rate-limited to 30/min per IP and costs about 10x a filter, so
prefer where when an exact match would do.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/data/summaries/query" \
-H "X-App-Slug: sred-desk" -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"where":{"claim_year":{"eq":"2026"}},"sort":{"field":"ran_at","dir":"desc"},"limit":10}'
q = {"where": {"claim_year": {"eq": "2026"}},
"sort": {"field": "ran_at", "dir": "desc"}, "limit": 10}
rows = requests.post(f"{API}/data/summaries/query", headers=H, json=q).json()["data"]
for rec in rows["records"]:
print(rec["doc"]["title"], rec["doc"]["ran_at"]) # fields live under rec["doc"]
const q = { where: { claim_year: { eq: "2026" } },
sort: { field: "ran_at", dir: "desc" }, limit: 10 };
const rows = (await (await fetch(`${API}/data/summaries/query`, {
method: "POST", headers: H, body: JSON.stringify(q),
})).json()).data;
rows.records.forEach(r => console.log(r.doc.title, r.doc.ran_at));
q := `{"where":{"claim_year":{"eq":"2026"}},"sort":{"field":"ran_at","dir":"desc"},"limit":10}`
req, _ = http.NewRequest("POST", api+"/data/summaries/query", strings.NewReader(q))
req.Header.Set("X-App-Slug", "sred-desk")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ = http.DefaultClient.Do(req) // records[i].doc carries the summary fields
String q = "{\"where\":{\"claim_year\":{\"eq\":\"2026\"}},"
+ "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":10}";
var qReq = HttpRequest.newBuilder(URI.create(api + "/data/summaries/query"))
.header("X-App-Slug", "sred-desk")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(q)).build();
System.out.println(client.send(qReq, HttpResponse.BodyHandlers.ofString()).body());
uri = URI("#{API}/data/summaries/query")
req = Net::HTTP::Post.new(uri, "X-App-Slug" => "sred-desk",
"Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json")
req.body = { where: { claim_year: { eq: "2026" } },
sort: { field: "ran_at", dir: "desc" }, limit: 10 }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body).dig("data", "records").each { |r| puts r.dig("doc", "title") }
$q = ["where" => ["claim_year" => ["eq" => "2026"]],
"sort" => ["field" => "ran_at", "dir" => "desc"], "limit" => 10];
$ch = curl_init("$api/data/summaries/query");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-App-Slug: sred-desk", "Authorization: Bearer $token",
"Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode($q)]);
foreach (json_decode(curl_exec($ch), true)["data"]["records"] as $r)
echo $r["doc"]["title"], "\n";
var q = new { where = new { claim_year = new { eq = "2026" } },
sort = new { field = "ran_at", dir = "desc" }, limit = 10 };
var rows = await http.PostAsJsonAsync($"{api}/data/summaries/query", q);
Console.WriteLine(await rows.Content.ReadAsStringAsync()); // records[i].doc.*
Semantic search: POST /data/summaries/similar with
{"text": "the sampling uncertainty year", "limit": 8} returns records scored
by meaning over the embedded title, subject and
summary fields. Records come back as {record_id, doc: {...}}
— the fields are under doc, never flat on the record.
Fair use
Runs are billed to the calling account at the model's rates plus the app's 10% markup;
/estimate, /me and /guest are free. Do not poll jobs
faster than every 2 seconds, reuse one token rather than minting a guest per call (guest
subjects each own their records — a fresh guest sees an empty collection), and keep
Idempotency-Key on every run so retries are safe. This page and the app obey
the same contract; when in doubt, read sredkit.js and app.js in
this bundle — they are the reference client. Output is a drafting aid, not tax advice.