Drive PK Desk from your own code
Everything the web page does is available over HTTP: post a concentration-time table together with the non-compartmental facts the page computes (or that you compute the same way), and get back either a reviewed profile — every parameter graded, every quality flag answered, a verdict of reportable, reportable with caveats or not reportable — or a judged regimen: a proposed dose and interval assessed against a target window from the superposition prediction. The natural use is a pipeline that runs every subject of a study through the review overnight, or a notebook that asks the same question of a dozen candidate regimens.
One thing is different from most apps: the model never computes a number. nca_facts (and
regimen_facts) are inputs you send, and the reply's value_used fields are checked
against them. The web page builds those facts with pkcalc.js, which is served from this origin
as plain JavaScript and runs unchanged in Node — global.window = {}; require("./pkcalc.js");
window.PkCalc.analyse(form) — so a script can produce facts identical to the page's.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }
Send your token as Authorization: Bearer … on every call. The app slug travels in the
body of /guest as {"slug": "pk-desk"}; after that the token itself carries
the app, so a run needs only Authorization, Content-Type: application/json and the
Idempotency-Key described in step 5.
The request body for /estimate, /run and /run-stream is the input
object itself — not wrapped in anything. A body of {"input": {…}} returns 200 and quietly hides
every field from the model. /estimate also approves a body that is not an object at all, so
guard it the way the app's own client does: refuse to send anything that is not a plain JSON object.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits, or a guest token tried a metered run. Call /estimate first, then sign in and top up. |
validation_error | 400 | The body is not a plain JSON object, or a field is the wrong type — data must be a string, nca_facts an object, proposal and targets objects. |
not_found | 404 | Unknown job id, or the app slug does not exist. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop a poll. |
internal | 500 | A server-side failure. Retry with the same Idempotency-Key so you are not billed twice. |
The field to get right first: task
PK Desk is one app with two lanes, and task is what chooses between them. It is the first field of every request body:
task | what comes back | extra input fields |
|---|---|---|
"profile" | The review: drug_context, phases, parameters graded reliable / caution / unreliable / not_estimable with the value used, findings with a severity and a recommendation, sampling_advice, next_steps, coverage of every quality flag, and a verdict of reportable, reportable_with_caveats or not_reportable. | none |
"regimen" | The judgement: assessment against the targets, predictions_reviewed, loading_dose, alternatives drawn from the facts, risks, monitoring, assumptions, and the same coverage. | proposal, targets, regimen_facts |
The system prompt routes on task and never blends the two contracts in one reply. A missing or
unrecognised task is not an error: the model answers the closest lane, names it in the reply's
lane field, and says so in notes_on_input. So branch on lane in the reply,
never on the task you believe you sent.
The two lanes chain. Run profile over a table; when its verdict is reportable,
send the same table and nca_facts with a proposal, targets and
regimen_facts as a regimen run. The web page does this with one button.
1. Get a token
A guest token is enough for /me and /estimate. Running a lane is metered, so it needs a personal token: sign in on the token page and copy it from there — no developer console needed.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug": "pk-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest",...}}
# A guest token can call /me and /estimate. Running a lane is metered and needs a
# personal token: sign in on https://pk-desk.skillsafe.ai/tokens.html and copy it.
import requests
r = requests.post("https://api.skillsafe.ai/v1/app-api/guest", json={"slug": "pk-desk"}, timeout=30)
token = r.json()["data"]["token"] # guest: /me and /estimate only
# For metered runs, paste the personal token from https://pk-desk.skillsafe.ai/tokens.html
# TOKEN = "YOUR_TOKEN"
const r = await fetch("https://api.skillsafe.ai/v1/app-api/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug: "pk-desk" }) });
const token = (await r.json()).data.token; // guest: /me and /estimate only
// For metered runs use the personal token from https://pk-desk.skillsafe.ai/tokens.html
body := strings.NewReader(`{"slug":"pk-desk"}`)
resp, err := http.Post("https://api.skillsafe.ai/v1/app-api/guest", "application/json", body)
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
var out struct{ Data struct{ Token string `json:"token"` } `json:"data"` }
json.NewDecoder(resp.Body).Decode(&out)
token := out.Data.Token // guest: /me and /estimate only
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"pk-desk\"}")).build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
// parse res.body() with your JSON library; data.token is the guest token
require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, { slug: "pk-desk" }.to_json, "Content-Type" => "application/json")
token = JSON.parse(res.body)["data"]["token"] # guest: /me and /estimate only
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["slug" => "pk-desk"])]);
$token = json_decode(curl_exec($ch), true)["data"]["token"]; // guest: /me and /estimate only
using var http = new HttpClient();
var res = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", new StringContent("{\"slug\":\"pk-desk\"}", Encoding.UTF8, "application/json"));
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var token = doc.RootElement.GetProperty("data").GetProperty("token").GetString(); // guest: /me and /estimate only
2. A tiny client
Eight lines that add the headers, unwrap the envelope, turn ok: false into an exception, and refuse to send a body that is not a plain object.
# cURL has no state; repeat the headers on every call.
TOKEN="YOUR_TOKEN"
API="https://api.skillsafe.ai/v1/app-api"
call() { curl -s -X "$1" "$API$2" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" "${@:3}"; }
import json, requests
class PkDesk:
def __init__(self, token, base="https://api.skillsafe.ai/v1/app-api"):
self.s = requests.Session(); self.s.headers.update({"Authorization": f"Bearer {token}", "Content-Type": "application/json"}); self.base = base
def call(self, method, path, body=None, headers=None):
if body is not None and not isinstance(body, dict):
raise TypeError("the run body must be a plain object") # /estimate approves anything; guard client-side
r = self.s.request(method, self.base + path, data=json.dumps(body) if body is not None else None, headers=headers or {}, timeout=120)
out = r.json()
if not out.get("ok"):
raise RuntimeError(f"{out['error']['code']}: {out['error']['message']}")
return out["data"]
class PkDesk {
constructor(token, base = "https://api.skillsafe.ai/v1/app-api") { this.token = token; this.base = base; }
async call(method, path, body, headers = {}) {
if (body !== undefined && (!body || typeof body !== "object" || Array.isArray(body))) throw new TypeError("the run body must be a plain object");
const r = await fetch(this.base + path, { method, headers: { Authorization: "Bearer " + this.token, "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body) });
const out = await r.json();
if (!out.ok) throw new Error(out.error.code + ": " + out.error.message);
return out.data;
}
}
type Client struct { Token, Base string }
func (c Client) Call(method, path string, body any, headers map[string]string) (map[string]any, error) {
var rd io.Reader
if body != nil { b, _ := json.Marshal(body); rd = bytes.NewReader(b) }
req, _ := http.NewRequest(method, c.Base+path, rd)
req.Header.Set("Authorization", "Bearer "+c.Token); req.Header.Set("Content-Type", "application/json")
for k, v := range headers { req.Header.Set(k, v) }
resp, err := http.DefaultClient.Do(req); if err != nil { return nil, err }
defer resp.Body.Close()
var out struct{ Ok bool; Data map[string]any; Error struct{ Code, Message string } }
json.NewDecoder(resp.Body).Decode(&out)
if !out.Ok { return nil, fmt.Errorf("%s: %s", out.Error.Code, out.Error.Message) }
return out.Data, nil
}
record PkDesk(String token, String base) {
HttpResponse<String> call(String method, String path, String jsonBody, Map<String,String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(base + path)).header("Authorization", "Bearer " + token).header("Content-Type", "application/json");
headers.forEach(b::header);
b.method(method, jsonBody == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(jsonBody));
return HttpClient.newHttpClient().send(b.build(), HttpResponse.BodyHandlers.ofString());
}
}
// base = "https://api.skillsafe.ai/v1/app-api"; check "ok" in the JSON before reading "data"
require "net/http"; require "json"
class PkDesk
def initialize(token, base = "https://api.skillsafe.ai/v1/app-api"); @token = token; @base = base; end
def call(method, path, body = nil, headers = {})
raise TypeError, "the run body must be a Hash" if body && !body.is_a?(Hash)
uri = URI(@base + path)
req = Net::HTTP.const_get(method.capitalize).new(uri, { "Authorization" => "Bearer #{@token}", "Content-Type" => "application/json" }.merge(headers))
req.body = body.to_json if body
out = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise "#{out["error"]["code"]}: #{out["error"]["message"]}" unless out["ok"]
out["data"]
end
end
class PkDesk {
function __construct(private string $token, private string $base = "https://api.skillsafe.ai/v1/app-api") {}
function call(string $method, string $path, ?array $body = null, array $headers = []): array {
$ch = curl_init($this->base . $path);
$h = array_merge(["Authorization: Bearer " . $this->token, "Content-Type: application/json"], $headers);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $h]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$out = json_decode(curl_exec($ch), true);
if (!$out["ok"]) throw new RuntimeException($out["error"]["code"] . ": " . $out["error"]["message"]);
return $out["data"];
}
}
class PkDesk {
readonly HttpClient http = new(); readonly string token; readonly string baseUrl;
public PkDesk(string token, string baseUrl = "https://api.skillsafe.ai/v1/app-api") { this.token = token; this.baseUrl = baseUrl; }
public async Task<JsonElement> Call(HttpMethod method, string path, object? body = null, Dictionary<string,string>? headers = null) {
var req = new HttpRequestMessage(method, baseUrl + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
if (headers != null) foreach (var kv in headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
var doc = JsonDocument.Parse(await (await http.SendAsync(req)).Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean()) throw new Exception(doc.RootElement.GetProperty("error").GetProperty("code").GetString());
return doc.RootElement.GetProperty("data");
}
}
3. Check the session and the balance
/me returns subject_type (user or guest), subject_id and credits — nothing else. A signed-in session is subject_type == "user".
call GET /me
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
data = api.call("GET", "/me")
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
const data = await api.call("GET", "/me");
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
data, err := c.Call("GET", "/me", nil, nil)
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
var res = api.call("GET", "/me", null, Map.of());
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
data = api.call("get", "/me")
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
$data = $api->call("GET", "/me");
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
var data = await api.Call(HttpMethod.Get, "/me");
# {"subject_type":"user","subject_id":"usr_...","credits":48210}
4. Price the run — free
/estimate takes the exact body you will run and returns hold_credits (the reservation,
priced at the full output cap), min_credits, model, model_alias and
markup_bps. It creates no job and costs nothing. Note that it validates nothing about the body's
shape either — a correct model in the answer proves the app binding, not your input.
The profile body, exactly as the web page sends it (facts abbreviated to the fields shown; the page sends every subject and the full summary):
{
"task": "profile",
"data": "time_h,conc_mg_L\n0,0.000\n0.25,2.911\n0.5,4.614\n1,6.868\n1.5,7.423\n2,7.754\n3,7.265\n4,6.975\n6,5.712\n8,4.902\n12,3.364\n16,2.464\n24,1.202",
"route": "extravascular",
"dose": "320",
"dose_unit": "mg",
"weight_kg": "",
"infusion_duration": "",
"time_unit": "h",
"conc_unit": "mg/L",
"dosing_state": "single",
"tau": "",
"context_hint": "Theophylline, healthy adult volunteer, plasma, single 320 mg oral dose.",
"nca_facts": {
"units": {
"time": "h",
"concentration": "mg/L",
"dose": "mg",
"clearance": "L/h",
"volume": "L"
},
"route": "extravascular",
"dose_mg": 320,
"steady_state": false,
"f_known": false,
"n_subjects": 1,
"subjects": [
{
"subject": "1",
"n_points": 13,
"cmax": 7.754,
"tmax": 2,
"auc_last": 91.37,
"auc_inf": 105.2,
"pct_extrap": 13.11,
"lambda_z": 0.08721,
"half_life": 7.948,
"cl": 3.043,
"vz": 34.89,
"terminal": {
"n_points": 6,
"adj_r2": 0.9993,
"span_half_lives": 2.517
}
}
],
"summary": {},
"flags": [],
"clipped": {
"cut": 0
}
}
}
call POST /estimate -d @body.json
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
data = api.call("POST", "/estimate", body)
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
const data = await api.call("POST", "/estimate", body);
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
data, err := c.Call("POST", "/estimate", body, nil)
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
var res = api.call("POST", "/estimate", bodyJson, Map.of());
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
data = api.call("post", "/estimate", body)
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
$data = $api->call("POST", "/estimate", $body);
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
var data = await api.Call(HttpMethod.Post, "/estimate", body);
# {"hold_credits":2958,"min_credits":600,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"sponsor_enabled":false}
nca_facts, and why a script must send it
The prompt forbids the model from computing anything: it grades and interprets the numbers in
nca_facts and copies them into parameters[].value_used. Without the facts the reply
has nothing to cite. Build them with the same code the page uses — pkcalc.js from this origin runs
unchanged in Node:
global.window = {};
require("./pkcalc.js"); // curl -sO https://pk-desk.skillsafe.ai/pkcalc.js
const P = window.PkCalc;
const a = P.analyse({ data: table, dose: "320", dose_unit: "mg", route: "extravascular", time_unit: "h", conc_unit: "mg/L", dosing_state: "single", tau: "" });
body.nca_facts = P.toFacts(a, { cut: 0 });
// regimen lane:
const s = a.subjects.find(x => x.terminal);
const reg = { tau_h: 8, dose_mg: 300, dose_ref_mg: a.dose_mg, loading_mg: null };
const pred = P.superpose(s, reg), alts = P.alternatives(s, reg), assess = P.assessRegimen(pred, targets);
body.regimen_facts = P.regimenFacts(pred, alts, assess, targets, reg);
The regimen body adds proposal, targets (mg/L) and regimen_facts:
{
"task": "regimen",
"proposal": {
"dose_mg": 300,
"tau_h": 8,
"loading_mg": null
},
"targets": {
"cmin_min": 5,
"cmax_max": 20,
"cavg_target": null
},
"regimen_facts": {
"proposed": {
"dose_mg": 300,
"tau_h": 8,
"reference_dose_mg": 320,
"dose_scale": 0.9375
},
"targets": {
"cmin_min": 5,
"cmax_max": 20,
"cavg_target": null
},
"derived_assessment": "meets_target",
"cmax_ss": 14.94,
"cmin_ss": 9.149,
"cavg_ss": 12.32,
"accumulation_ratio_auc": 2.128,
"accumulation_ratio_lz": 1.991,
"time_to_90pct_ss_h": 26.4,
"doses_to_90pct_ss": 4,
"suggested_loading_mg": 597.3,
"alternatives": []
}
}
5. Run it, then poll
/run returns a job_id at once; poll /jobs/{id} until status is
succeeded or failed. The reply text is at output.output; the actual charge at
charged_credits, usually far below the hold. Always send an Idempotency-Key.
call POST /run -H "Idempotency-Key: pk-desk:profile:<hash-of-input>:a1" -d @body.json
# The lane and an attempt counter are in the key: two lanes over the same table are
# two runs; a retry of the SAME body reuses the key and is never billed twice.
key = f"pk-desk:{body['task']}:{hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]}:a1"
data = api.call("POST", "/run", body, {"Idempotency-Key": key})
const key = `pk-desk:${body.task}:${await sha16(JSON.stringify(body))}:a1`; // any stable hash of the input
const data = await api.call("POST", "/run", body, { "Idempotency-Key": key });
key := fmt.Sprintf("pk-desk:%s:%x:a1", body["task"], sha256.Sum256(raw))[:64]
data, err := c.Call("POST", "/run", body, map[string]string{"Idempotency-Key": key})
var key = "pk-desk:" + task + ":" + sha16(bodyJson) + ":a1";
var res = api.call("POST", "/run", bodyJson, Map.of("Idempotency-Key", key));
key = "pk-desk:#{body[:task]}:#{Digest::SHA256.hexdigest(body.to_json)[0, 16]}:a1"
data = api.call("post", "/run", body, { "Idempotency-Key" => key })
$key = "pk-desk:" . $body["task"] . ":" . substr(hash("sha256", json_encode($body)), 0, 16) . ":a1";
$data = $api->call("POST", "/run", $body, ["Idempotency-Key: $key"]);
var key = $"pk-desk:{task}:{Sha16(bodyJson)}:a1";
var data = await api.Call(HttpMethod.Post, "/run", body, new() { ["Idempotency-Key"] = key });
JOB=$(call POST /run -H "Idempotency-Key: $KEY" -d @body.json | jq -r .data.job_id)
while :; do S=$(call GET /jobs/$JOB); st=$(echo "$S" | jq -r .data.status); [ "$st" = succeeded ] && break; [ "$st" = failed ] && exit 1; sleep 2; done
echo "$S" | jq -r .data.output.output # the reply text: one JSON object
job = api.call("POST", "/run", body, {"Idempotency-Key": key})["job_id"]
while True:
j = api.call("GET", f"/jobs/{job}")
if j["status"] in ("succeeded", "failed"): break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"))
const { job_id } = await api.call("POST", "/run", body, { "Idempotency-Key": key });
let j; do { await new Promise(r => setTimeout(r, 2000)); j = await api.call("GET", `/jobs/${job_id}`); } while (!["succeeded", "failed"].includes(j.status));
const reply = JSON.parse(j.output.output); // the lane's JSON object
job, _ := c.Call("POST", "/run", body, map[string]string{"Idempotency-Key": key})
id := job["job_id"].(string)
for {
j, _ := c.Call("GET", "/jobs/"+id, nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" { break }
time.Sleep(2 * time.Second)
}
var job = api.call("POST", "/run", bodyJson, Map.of("Idempotency-Key", key)); // read data.job_id
for (;;) { var j = api.call("GET", "/jobs/" + jobId, null, Map.of()); /* read data.status */ if (done) break; Thread.sleep(2000); }
job = api.call("post", "/run", body, { "Idempotency-Key" => key })["job_id"]
loop do
j = api.call("get", "/jobs/#{job}")
break if %w[succeeded failed].include?(j["status"])
sleep 2
end
reply = JSON.parse(j["output"]["output"])
$job = $api->call("POST", "/run", $body, ["Idempotency-Key: $key"])["job_id"];
do { sleep(2); $j = $api->call("GET", "/jobs/$job"); } while (!in_array($j["status"], ["succeeded", "failed"]));
$reply = json_decode($j["output"]["output"], true);
var job = (await api.Call(HttpMethod.Post, "/run", body, new() { ["Idempotency-Key"] = key })).GetProperty("job_id").GetString();
JsonElement j; do { await Task.Delay(2000); j = await api.Call(HttpMethod.Get, $"/jobs/{job}"); } while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));
6. Or stream it
/run-stream is the same call over server-sent events: an event: job frame with the id,
event: delta frames carrying {"text": "..."} (a plain string in text),
and a final event: done frame with the job. Browsers receive heartbeat ticks instead of deltas — a
script gets the deltas.
call POST /run-stream -H "Idempotency-Key: $KEY" -H "Accept: text/event-stream" -d @body.json
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"lane\":\"profile\",..."} (repeated; browsers get ticks instead)
# event: done data: {"status":"succeeded","output":{"output":"..."},"charged_credits":1234}
with api.s.post(api.base + "/run-stream", data=json.dumps(body), headers={"Idempotency-Key": key, "Accept": "text/event-stream"}, stream=True, timeout=300) as r:
buf = ""
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data:"):
ev = json.loads(line[5:])
if "text" in ev: buf += ev["text"]
if ev.get("status"): final = ev
reply = json.loads(final["output"]["output"] or buf)
const r = await fetch(api.base + "/run-stream", { method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/json", Accept: "text/event-stream", "Idempotency-Key": key }, body: JSON.stringify(body) });
const reader = r.body.getReader(); let text = "", final = null;
for (;;) { const { value, done } = await reader.read(); if (done) break; for (const line of new TextDecoder().decode(value).split("\n")) if (line.startsWith("data:")) { const ev = JSON.parse(line.slice(5)); if (ev.text) text += ev.text; if (ev.status) final = ev; } }
req.Header.Set("Accept", "text/event-stream"); req.Header.Set("Idempotency-Key", key)
resp, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(resp.Body)
for sc.Scan() { if line := sc.Text(); strings.HasPrefix(line, "data:") { /* json-decode line[5:]; append .text, keep the frame with .status */ } }
var req = HttpRequest.newBuilder(URI.create(base + "/run-stream")).header("Authorization", "Bearer " + token).header("Content-Type", "application/json").header("Accept", "text/event-stream").header("Idempotency-Key", key).POST(HttpRequest.BodyPublishers.ofString(bodyJson)).build();
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(l -> { /* parse l.substring(5) */ });
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
req = Net::HTTP::Post.new(URI(api_base + "/run-stream"), { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json", "Accept" => "text/event-stream", "Idempotency-Key" => key })
req.body = body.to_json
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| handle(JSON.parse(l[5..])) if l.start_with?("data:") } } }
end
$ch = curl_init($base . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json", "Accept: text/event-stream", "Idempotency-Key: $key"], CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) handle(json_decode(substr($l, 5), true)); return strlen($chunk); }]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, baseUrl + "/run-stream") { Content = new StringContent(bodyJson, Encoding.UTF8, "application/json") };
req.Headers.Authorization = new("Bearer", token); req.Headers.Accept.Add(new("text/event-stream")); req.Headers.Add("Idempotency-Key", key);
using var s = await (await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead)).Content.ReadAsStreamAsync();
using var rd = new StreamReader(s); string? line; while ((line = await rd.ReadLineAsync()) != null) if (line.StartsWith("data:")) Handle(JsonDocument.Parse(line[5..]));
7. Parse the result
The reply is one JSON object. Strip code fences if a proxy added them, take the substring from the first
{ to the last }, and JSON.parse. Then run the page's own checks:
# The reply is one JSON object. Branch on reply.lane, never on the task you sent.
echo "$REPLY" | jq '{lane, title, verdict, findings: [.findings[]? | {id, severity, topic}], coverage: (.coverage | length)}'
reply = json.loads(text) # strip ``` fences first if a proxy added them
if reply["lane"] == "profile":
critical = [f for f in reply["findings"] if f["severity"] == "critical"]
verdict = "not_reportable" if critical else ("reportable_with_caveats" if any(f["severity"] == "major" for f in reply["findings"]) else "reportable")
assert verdict == reply["verdict"], "the verdict must follow the rule"
for p in reply["parameters"]: # every value the reviewer used must be the page's own number
ref = body["nca_facts"]["subjects"][0].get(KEY[p["name"]])
assert ref is None or abs(p["value_used"] - ref) <= 0.02 * abs(ref)
else:
assert reply["assessment"] == body["regimen_facts"]["derived_assessment"] or reply["assessment_reason"]
const reply = JSON.parse(text);
if (reply.lane === "profile") {
const sev = reply.findings.map(f => f.severity);
const derived = sev.includes("critical") ? "not_reportable" : sev.includes("major") ? "reportable_with_caveats" : "reportable";
if (derived !== reply.verdict) console.warn("verdict does not follow the rule");
} else if (reply.assessment !== body.regimen_facts.derived_assessment && !reply.assessment_reason) console.warn("assessment differs without a reason");
var reply struct{ Lane, Verdict, Assessment string; Findings []struct{ Severity string } }
json.Unmarshal([]byte(text), &reply)
// derive the verdict from Findings the same way the page does and compare
var reply = mapper.readTree(text);
if (reply.get("lane").asText().equals("profile")) { /* derive the verdict from findings[].severity and compare with reply.verdict */ }
reply = JSON.parse(text)
if reply["lane"] == "profile"
sev = reply["findings"].map { |f| f["severity"] }
derived = sev.include?("critical") ? "not_reportable" : sev.include?("major") ? "reportable_with_caveats" : "reportable"
warn "verdict off-rule" unless derived == reply["verdict"]
end
$reply = json_decode($text, true);
if ($reply["lane"] === "profile") { $sev = array_column($reply["findings"], "severity"); $derived = in_array("critical", $sev) ? "not_reportable" : (in_array("major", $sev) ? "reportable_with_caveats" : "reportable"); }
var reply = JsonDocument.Parse(text).RootElement;
if (reply.GetProperty("lane").GetString() == "profile") { /* derive the verdict from findings[].severity and compare */ }
The verdict rule
not_reportable if any finding is critical; else reportable_with_caveats if any is major; else reportable. The page derives it from the findings and shows the derived one when the reply disagrees. For the regimen lane, assessment must equal regimen_facts.derived_assessment unless assessment_reason explains why not.
The value checks
Every parameters[].value_used (profile) and predictions_reviewed[].value_used (regimen) is compared to the corresponding number in the facts you sent, within 2%. The parameter names are canonical: AUClast, AUCinf, %AUCextrap, Cmax, Tmax, Clast, lambda_z, half-life, MRT, CL or CL/F, Vz or Vz/F, Vss, C0, AUCtau, Cavg, Cmin; predictions are Cmax,ss, Cmin,ss, Cavg,ss, accumulation ratio, time to 90% steady state, half-life. Every flag id in nca_facts.flags must appear exactly once in coverage.
The output contract
Common envelope
{ "lane": "profile" | "regimen", "title": string, "summary": string,
"coverage": [ { "id": "Q-001", "status": "confirmed" | "downgraded" | "dismissed" | "merged", "ref": string, "note": string } ],
"notes_on_input": string, ...task body }
The profile body
{ "drug_context": string,
"phases": [ { "phase": "absorption" | "distribution" | "elimination" | "other", "observation": string, "evidence": string } ],
"parameters": [ { "name": string, "value_used": number | null, "unit": string, "assessment": "reliable" | "caution" | "unreliable" | "not_estimable", "note": string, "subject": string } ],
"findings": [ { "id": "F-001", "severity": "critical" | "major" | "minor" | "info", "topic": "sampling" | "terminal_phase" | "extrapolation" | "blq" | "units" | "dose" | "variability" | "absorption" | "interpretation" | "reporting" | "other", "problem": string, "recommendation": string } ],
"sampling_advice": [string], "next_steps": [string],
"verdict": "reportable" | "reportable_with_caveats" | "not_reportable", "verdict_reason": string }
The regimen body
{ "assessment": "meets_target" | "partially_meets_target" | "misses_target" | "no_target_given" | "not_predictable", "assessment_reason": string,
"predictions_reviewed": [ { "name": string, "value_used": number | null, "unit": string, "note": string } ],
"loading_dose": { "recommended": boolean, "dose_mg": number | null, "rationale": string },
"alternatives": [ { "dose_mg": number, "tau_h": number, "cmin_ss": number | null, "cmax_ss": number | null, "cavg_ss": number | null, "comment": string } ],
"risks": [ { "id": "R-001", "severity": "critical" | "major" | "minor" | "info", "topic": "linearity" | "accumulation" | "variability" | "adherence" | "safety" | "assumption" | "monitoring" | "other", "problem": string, "mitigation": string } ],
"monitoring": [string], "assumptions": [string] }
8. Use it in a pipeline
Loop over the subjects of a study, build each subject's nca_facts with pkcalc.js, run
the profile lane, and collect the verdicts. Fail the batch when any subject is
not_reportable, and write the findings CSV the study report will need. Price the batch first with
/estimate — the hold is per run, and the charge is usually a third of it.
Truncation and partial results
When the balance sits between min_credits and hold_credits the run still executes with
a reduced output cap and the job carries "truncated": true. The reply may then stop mid-object;
close the open strings and brackets before parsing, keep whatever sections arrived, and treat the verdict as
missing rather than defaulting it.
PK Desk is derived from @k-dense-ai/pkpd-modeling. It is not medical advice.