← PK Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits, or a guest token tried a metered run. Call /estimate first, then sign in and top up.
validation_error400The 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_found404Unknown job id, or the app slug does not exist.
rate_limited429Too many requests. Back off and retry; do not tight-loop a poll.
internal500A 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:

taskwhat comes backextra 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.

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}"; }

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}

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}

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.
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

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}

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)}'

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.