Run Trial Protocol Desk from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the synopsis and works out the sample size for free - the evaluable and enrolled numbers by a two-sample t-test or a two-proportion z-test, the sensitivity at 90% power and at a 10% smaller effect, and the power the planned enrolment really buys - and checks the synopsis for what a protocol needs: a primary endpoint with a timepoint that is a scheduled visit, a comparator, randomisation and blinding, a non-inferiority margin, sample-size assumptions, measurable criteria, a visit schedule, safety monitoring, a data monitoring committee, an analysis plan and no promotional claims. That is tpscan.js, and every finding carries its rule id, line and fix. The run critiques the design ("critique") or drafts the core protocol sections ("draft"); the page then holds the reply to its own reading (tpreply.js): every rule answered, every sample-size number one of its own, no invented trial numbers or guidance titles.
A drafting aid, not advice. Nothing here or in a reply is medical, statistical or regulatory advice. The sample size is preliminary and needs a biostatistician's review before it goes into a protocol, and the protocol itself needs the sponsor's clinical, regulatory and ethics review.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"trial-protocol-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a field the run needs (task, synopsis, facts) is missing or not a string. Send every field as a string; focus may be "". |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes - "critique" (judge the design before anyone writes the protocol: a verdict, findings with fixes, a call on every page finding, a review of seven design topics, a word on the sample size and the questions the team must settle) and "draft" (the eight core protocol sections drafted from the synopsis, fixing what the page flagged where the synopsis allows and raising the rest as open questions). The TP- rules belong to both. A missing or unknown task is answered as critique, with a caveat saying so. Both lanes send the same fields.
The input
Every field is a string.
| Field | Required | What |
|---|---|---|
task | yes | "critique" or "draft". The model answers that task and only that task. |
synopsis | yes | the study synopsis as pasted, in the synopsis format. The page sends up to 16,000 characters - the synopsis, not the full protocol - and needs at least an Intervention:, Design: or Primary endpoint: line. |
focus | no | what the team wants weighed: timelines, sites, a regulator's feedback - or a critique to apply in a draft (see critique, then draft). Up to 1,500 characters on the page. May be ""; when given, the summary says how it changed the answer. |
facts | yes | the page's own reading of the same synopsis: the design it read, the primary endpoint and its timepoint, the sample size with every assumption, the Schedule of Activities, and every finding as rule | severity | line | text. See the facts format. |
retry_note | no | present only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise. |
The synopsis format
synopsis is plain text: Key: value lines, one field per line, with lists as bullets (- or 1. ) on the lines under their key. The keys the page reads:
- The study:
Title,Intervention,Product type,Phase,Indication,Design,Control,Blinding(alsoRationale,Sites,Duration). - Endpoints:
Primary endpoint(with its timepoint -at week 8) andSecondary endpoints, as bullets. - The sample-size assumptions:
Endpoint type(continuous or binary),DifferenceandSD(continuous) orControl rateandTreatment rate(binary),Margin(non-inferiority),Alpha,Power,DropoutandPlanned enrolment. The allocation ratio is read fromDesign(randomized 1:1), or fromAllocation. - The population:
InclusionandExclusion, one criterion per bullet. - Operations:
Visits(comma-separated, or bullets),Assessments(one bullet each:Sleep diary: Baseline, Week 8, orallfor every visit),Safety,DSMBandAnalysis.
Title: A randomised trial of a digital CBT-I program for chronic insomnia
Design: randomized 1:1, SleepPath versus sleep-hygiene education app, parallel group
Primary endpoint: insomnia remission (Insomnia Severity Index below 8) at week 8
Endpoint type: binary
Control rate: 20%
Treatment rate: 35%
Dropout: 20%
Planned enrolment: 380
Inclusion:
- Adults 18 to 70 meeting DSM-5 criteria for insomnia disorder for at least 3 months
Visits: Screening, Baseline, Week 4, Week 8, Week 24
Assessments:
- Sleep diary: Baseline, Week 8
- PHQ-9 suicidality item: all
A field the synopsis does not give is simply left out; the page reports what that costs as a finding.
The free sample-size calculation
The page computes the sample size in the browser, for free, from the synopsis's own assumptions - the formulas of the source skill's sample_size_calculator.py, identical to it at 1:1 allocation:
- Continuous endpoint - two-sample t-test (normal approximation) from the
Differenceand theSD: per control armn = (1 + 1/r) (z_a + z_b)^2 / (difference / SD)^2, the treatment armrtimes that, whereris treatment per control. - Binary endpoint - two-proportion z-test from the
Control rateand theTreatment rate(pooled variance under the null, the arms' own variances under the alternative). - Superiority uses a two-sided alpha; non-inferiority a one-sided alpha, with the margin (minus the expected true difference) as the effect.
- Arms are rounded up, then the enrolment is inflated for dropout:
enrol = ceil(evaluable / (1 - dropout)). - Sensitivity: the enrolment at 90% power, and at a 10% smaller effect (for non-inferiority, a 10% narrower margin).
- The power the
Planned enrolmentreally buys after dropout - below the target power, the page reportsTP-UNDERPOWERED. - When the synopsis leaves them out, it assumes alpha 0.05, power 80% and dropout 15%, and says so (
defaults used: ...).
The result goes into facts as one line, and the model is told to use only the numbers in that line: every evaluable, enrolled, per-arm or power figure in a reply must be one of them, and with no line it gives no number at all. So an API client must compute the sample size itself and send its own sample-size line in facts - with tpscan.js under node (Build the input yourself), or with your own calculator written in the same form:
Sample size (page): <binary|continuous> endpoint, <superiority|non-inferiority>, <test>, <two-sided|one-sided> alpha <a>, power <p>, allocation <1:1>, dropout <d>; <control X%, treatment Y%|difference D, SD S>[, margin M][ (defaults used: ...)]. Evaluable: <t> treatment + <c> control = <n>; to enrol after dropout: <N> (<t> + <c>). Sensitivity: 90% power needs <N90> enrolled; a 10% smaller effect needs <N10>. Planned enrolment <P> gives <power> power.
Sample size: not computed - <what is missing>.
The second form is for a synopsis without usable assumptions; the reply then says what is missing and gives no number. Whichever calculator you use, the figure is preliminary: have a biostatistician review it.
The facts format
facts is the page's own reading, written as plain text. The page builds it from the synopsis with tpscan.js, and the model answers against it. A client calling the API should send the same format (from tpscan.js run under node) or at least its own summary - the design as you read it, the primary endpoint and its timepoint, your sample-size line, and what you already know is missing. The check's format gets the most out of a run: the model answers every error and warn rule id it finds there with one prescan entry per id. The format, line by line:
<Title|Intervention|Product type|Phase|Indication|Design|Control|Blinding>: <as written - only the lines the synopsis gives>
Read by the page: <randomised|randomisation not stated>, <controlled|single-arm|no comparator named>, <blinding stated|blinding not stated>, <superiority|non-inferiority>, allocation <1:1>.
Primary endpoint: <endpoint[ | endpoint]|none>[ (timepoint read: week 8)]
Secondary endpoints: <a | b>
<the sample-size line>
Criteria: <n> inclusion criteria, <n> exclusion criteria. Visits: <visits|none>.
Schedule of Activities: <assessment> at <visits>; ...
Safety: ... / Data monitoring: ... / Analysis: ... (each on its own line, when given)
Prescan verdict: <ready|revise|rework> (<n> errors, <n> warnings, <n> notes).
Findings (rule | severity | line | text):
- <rule id> | <error|warn|info> | line <n> | <title>[ (note)][ | the synopsis text]
A synopsis with no findings gets the single line - none. The prescan verdict is rework at two or more errors, or with errors or warnings in four or more areas (endpoints, design, statistics, population, operations, safety, regulatory); revise on any error or warning; ready otherwise. A real example - the facts the page sends for the insomnia trial used in the worked example below:
Title: A randomised trial of a digital CBT-I program for chronic insomnia
Intervention: SleepPath, an app delivering six weekly sessions of cognitive behavioural therapy for insomnia
Product type: device (software)
Phase: pivotal
Indication: chronic insomnia disorder (DSM-5)
Design: randomized 1:1, SleepPath versus sleep-hygiene education app, parallel group
Control: sleep-hygiene education app with matched screen time
Blinding: participants are told both apps are active; outcome assessors are blinded
Read by the page: randomised, controlled, blinding stated, superiority, allocation 1:1.
Primary endpoint: insomnia remission (Insomnia Severity Index below 8) at week 8 (timepoint read: week 8)
Secondary endpoints: Change in Insomnia Severity Index at week 8 and week 24 | Sleep efficiency from a sleep diary at week 8
Sample size (page): binary endpoint, superiority, two-proportion z-test, two-sided alpha 0.05, power 80%, allocation 1:1, dropout 20%; control 20%, treatment 35%. Evaluable: 138 treatment + 138 control = 276; to enrol after dropout: 345 (173 + 173). Sensitivity: 90% power needs 463 enrolled; a 10% smaller effect needs 420. Planned enrolment 380 gives 83.7% power.
Criteria: 3 inclusion criteria, 4 exclusion criteria. Visits: Screening, Baseline, Week 4, Week 8, Week 24.
Schedule of Activities: Insomnia Severity Index at Screening, Baseline, Week 4, Week 8, Week 24; Sleep diary at Baseline, Week 8; PHQ-9 suicidality item at Screening, Baseline, Week 4, Week 8, Week 24.
Safety: adverse events and any PHQ-9 item 9 response above 0 reviewed by the study clinician within 24 hours
Data monitoring: an independent DSMB reviews unblinded safety data every 6 months; no efficacy stopping
Analysis: logistic regression of remission on arm, adjusted for baseline ISI, in the intention-to-treat population; missing week-8 data counted as non-remission
Prescan verdict: ready (0 errors, 0 warnings, 0 notes).
Findings (rule | severity | line | text):
- none
Rule ids the check uses, with their severity and area:
| Rule | Severity | Area | Title |
|---|---|---|---|
TP-NOENDPOINT | error | endpoints | No primary endpoint |
TP-NOTIMEPOINT | warn | endpoints | Primary endpoint without a timepoint |
TP-MULTIPRIMARY | warn | endpoints | More than one primary endpoint, no multiplicity plan |
TP-VISITMISMATCH | warn | endpoints | Primary timepoint is not a scheduled visit |
TP-NOCONTROL | warn | design | No comparator |
TP-NORANDOM | warn | design | Controlled but randomisation not stated |
TP-NOBLIND | info | design | Blinding not stated |
TP-NOASSUMPTION | error | statistics | No sample-size assumptions |
TP-NIMARGIN | error | statistics | Non-inferiority without a margin |
TP-NODROPOUT | warn | statistics | No dropout assumption |
TP-UNDERPOWERED | error | statistics | Planned enrolment is underpowered |
TP-NOANALYSIS | warn | statistics | No analysis method or population |
TP-NOINCL | warn | population | No inclusion criteria |
TP-NOEXCL | warn | population | No exclusion criteria |
TP-VAGUECRIT | info | population | Criterion without a measurable definition |
TP-NOVISITS | warn | operations | No visit schedule |
TP-NOSAFETY | warn | safety | No safety monitoring |
TP-NODSMB | info | safety | No data monitoring committee or stopping rules |
TP-PROMO | warn | regulatory | Promotional claims about an investigational product |
A finding the check cannot see (an endpoint that is not clinically meaningful, a comparator that is not standard of care, a population that cannot be recruited, a sham that unblinds, a missing-data plan that biases the result) comes back with the rule NEW.
The standard the model applies
The source skill - @anthropics/clinical-trial-protocol-skill (anthropics/healthcare), and the NIH/FDA protocol template it follows - is the only standard:
- One primary endpoint: specific, measurable, clinically meaningful, regulatory-acceptable, with a timepoint - and the synopsis, the endpoints table, the visit schedule and the statistical section name the same endpoint at the same timepoint. More than one primary endpoint needs a stated multiplicity strategy.
- Design: randomised and controlled unless a single-arm design against a performance goal is justified; the comparator named; who is blinded, or why it is open-label; allocation and stratification; superiority or non-inferiority, and for non-inferiority the margin and how it was chosen.
- Sample size from the calculation, never from habit - the page's numbers, with a planned enrolment below them called underpowered.
- Population: numbered, measurable inclusion and exclusion criteria - "good health" or "adequate renal function" name the test and the threshold.
- Operations and safety: visits with windows, a Schedule of Activities, adverse events collected, scored for severity (for example CTCAE) and reported with timelines, a data monitoring committee and stopping rules for pivotal, phase 3 or high-risk studies, the primary analysis, the analysis population and the missing-data plan.
- Regulatory language: an investigational product is under evaluation - never "safe", "effective", "proven" or a "cure".
House rules
- Every statement is grounded in the synopsis; no prior trial, NCT number, publication, guidance title, device classification or number is invented. What a protocol needs but the synopsis does not give is a question for the team. No clinical advice about individual patients.
- Every
errorandwarnfinding in the facts is answered by its rule id inprescan; a finding the model thinks is wrong is disputed with a reason, never silently dropped. - Every sample-size number in the reply is one of the page's numbers in the facts.
severitymapserror,warnandinfotohigh,mediumandlow;wherenames the synopsis field (Primary endpoint,Visits).- The reply is ONE JSON object and nothing else - no prose around it, no Markdown fences.
The replies
Both lanes carry task, summary, findings (rule - a TP- id from the facts or NEW - severity high / medium / low, title, where, why, fix; most severe first), prescan (rule, call agree or disagree, note) and caveats. The rest differs by lane.
critique
verdict on the design: ready (a protocol can be drafted as is), revise (fixable gaps) or rework (the study as designed cannot answer its question - wrong endpoint, no control, badly underpowered, unsafe). A page error the reply agrees with rules out ready.
summary- two or three sentences: what the study tests, the verdict, the first thing to fix.findingsandprescan.design_review- seven entries in this order: Endpoint, Control and blinding, Population, Sample size, Analysis, Safety oversight, Operations - each withtopic,assessment(one or two sentences) andrisklow,mediumorhigh.sample_size- one or two sentences (a string) on the page's calculation: whether its assumptions look defensible and what the planned enrolment buys, using only the page's numbers.questions- what the team must settle before drafting, each withfor(biostatistics,clinical,regulatoryoroperations) andquestion.caveats- what could not be judged from a synopsis.
{
"task": "critique",
"verdict": "revise",
"summary": "",
"findings": [{"rule": "TP-UNDERPOWERED", "severity": "high", "title": "", "where": "Planned enrolment", "why": "", "fix": ""}],
"prescan": [{"rule": "TP-UNDERPOWERED", "call": "agree", "note": ""}],
"design_review": [{"topic": "Endpoint", "assessment": "", "risk": "low"}],
"sample_size": "",
"questions": [{"for": "biostatistics", "question": ""}],
"caveats": [""]
}
draft
The core protocol sections, drafted from the synopsis: concise, specific to the study, a draft for the team to complete.
summary- two sentences: what was drafted and what is still open.sections- eight, in this order, each withid,titleandbody(plain text, numbered lists as1., 80 to 300 words):synopsis(Protocol Summary),objectives(Objectives and Endpoints, each with its endpoint and timepoint),design(Study Design),population(numbered inclusion and exclusion criteria),intervention(administration, comparator, compliance, concomitant therapy),schedule(visits with windows and the Schedule of Activities),safety(adverse event definitions, severity scale, reporting timelines, data monitoring and stopping rules) andstatistics(hypotheses, the page's sample size with its assumptions and sensitivity, analysis populations, primary analysis, missing data, multiplicity).sample_size- an object:enrol(the page's total to enrol, ornullwhen the page could not compute one) andstatement(the sample-size paragraph's key sentence).findings,prescan,open_questions(everything the draft needs but the synopsis does not give) andcaveats.
{
"task": "draft",
"summary": "",
"sections": [{"id": "synopsis", "title": "Protocol Summary", "body": ""}],
"sample_size": {"enrol": 264, "statement": ""},
"findings": [{"rule": "TP-VISITMISMATCH", "severity": "medium", "title": "", "where": "Visits", "why": "", "fix": ""}],
"prescan": [{"rule": "TP-VISITMISMATCH", "call": "agree", "note": ""}],
"open_questions": [""],
"caveats": [""]
}
The page holds either reply to its own reading: every error and warning answered by rule id, no rule id the page did not report other than NEW, every evaluable, enrolled, per-arm or power figure one of the page's (or of the synopsis's own numbers), no NCT number, guidance title or "et al." citation that is not in the synopsis, and no promotional claim about the investigational product. For critique it also wants all seven design-review topics, a word on the sample size, and no ready over an undisputed error. For draft it wants all eight sections in order, sample_size.enrol equal to the page's enrolment and that number in the statistics section, and the primary timepoint named in the synopsis, objectives, schedule and statistics sections. Each miss is shown next to the reply as a note.
Reading the reply
The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in tpreply.js). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'critique' - no prose, no code fences." and a new attempt number in the key (see step 5). In "draft", the sections read as one plain-text protocol draft - 1. PROTOCOL SUMMARY, the body, and so on - which is what the page exports and what step 5 prints. Check every number in it against your own calculation before the draft goes to a biostatistician.
Critique, then draft
A critique can be handed to a "draft" run as its focus, so the draft applies it. The page builds that focus from the critique reply, one line each, clipped to 1,500 characters: Apply this critique (Trial Protocol Desk, verdict revise): <summary>, then - <rule> <title>: <fix> per finding and - Open (<for>): <question> per question. The same synopsis and facts go along.
Worked example - critiquing the insomnia trial
A clean pivotal design: SleepPath, a digital CBT-I app, against a sleep-hygiene education app with matched screen time, randomised 1:1, assessor-blinded, with remission (Insomnia Severity Index below 8) at week 8 as the binary primary endpoint. The page reads control 20% and treatment 35% at two-sided alpha 0.05, 80% power and 20% dropout: 276 evaluable, 345 to enrol (173 per arm), 463 at 90% power, 420 for a 10% smaller effect - and the planned 380 gives 83.7% power. It finds nothing wrong. The input below is exactly what the page sends for the "critique" lane; for the "draft" lane only task changes to "draft", and the same facts go along. Input:
{
"task": "critique",
"synopsis": "Title: A randomised trial of a digital CBT-I program for chronic insomnia\nIntervention: SleepPath, an app delivering six weekly sessions of cognitive behavioural therapy for insomnia\nProduct type: device (software)\nPhase: pivotal\nIndication: chronic insomnia disorder (DSM-5)\nDesign: randomized 1:1, SleepPath versus sleep-hygiene education app, parallel group\nControl: sleep-hygiene education app with matched screen time\nBlinding: participants are told both apps are active; outcome assessors are blinded\nPrimary endpoint: insomnia remission (Insomnia Severity Index below 8) at week 8\nSecondary endpoints:\n- Change in Insomnia Severity Index at week 8 and week 24\n- Sleep efficiency from a sleep diary at week 8\nEndpoint type: binary\nControl rate: 20%\nTreatment rate: 35%\nAlpha: 0.05\nPower: 80%\nDropout: 20%\nPlanned enrolment: 380\nInclusion:\n- Adults 18 to 70 meeting DSM-5 criteria for insomnia disorder for at least 3 months\n- Insomnia Severity Index of 15 or more at screening\n- Owns a smartphone and can use apps in English\nExclusion:\n- Untreated obstructive sleep apnoea (STOP-Bang 5 or more without a negative sleep study)\n- Bipolar disorder, psychosis or a seizure disorder\n- Shift work in the past 3 months\n- Current use of hypnotic medication more than 3 nights a week\nVisits: Screening, Baseline, Week 4, Week 8, Week 24\nAssessments:\n- Insomnia Severity Index: Screening, Baseline, Week 4, Week 8, Week 24\n- Sleep diary: Baseline, Week 8\n- PHQ-9 suicidality item: all\nSafety: adverse events and any PHQ-9 item 9 response above 0 reviewed by the study clinician within 24 hours\nDSMB: an independent DSMB reviews unblinded safety data every 6 months; no efficacy stopping\nAnalysis: logistic regression of remission on arm, adjusted for baseline ISI, in the intention-to-treat population; missing week-8 data counted as non-remission",
"focus": "",
"facts": "Title: A randomised trial of a digital CBT-I program for chronic insomnia\nIntervention: SleepPath, an app delivering six weekly sessions of cognitive behavioural therapy for insomnia\nProduct type: device (software)\nPhase: pivotal\nIndication: chronic insomnia disorder (DSM-5)\nDesign: randomized 1:1, SleepPath versus sleep-hygiene education app, parallel group\nControl: sleep-hygiene education app with matched screen time\nBlinding: participants are told both apps are active; outcome assessors are blinded\nRead by the page: randomised, controlled, blinding stated, superiority, allocation 1:1.\nPrimary endpoint: insomnia remission (Insomnia Severity Index below 8) at week 8 (timepoint read: week 8)\nSecondary endpoints: Change in Insomnia Severity Index at week 8 and week 24 | Sleep efficiency from a sleep diary at week 8\nSample size (page): binary endpoint, superiority, two-proportion z-test, two-sided alpha 0.05, power 80%, allocation 1:1, dropout 20%; control 20%, treatment 35%. Evaluable: 138 treatment + 138 control = 276; to enrol after dropout: 345 (173 + 173). Sensitivity: 90% power needs 463 enrolled; a 10% smaller effect needs 420. Planned enrolment 380 gives 83.7% power.\nCriteria: 3 inclusion criteria, 4 exclusion criteria. Visits: Screening, Baseline, Week 4, Week 8, Week 24.\nSchedule of Activities: Insomnia Severity Index at Screening, Baseline, Week 4, Week 8, Week 24; Sleep diary at Baseline, Week 8; PHQ-9 suicidality item at Screening, Baseline, Week 4, Week 8, Week 24.\nSafety: adverse events and any PHQ-9 item 9 response above 0 reviewed by the study clinician within 24 hours\nData monitoring: an independent DSMB reviews unblinded safety data every 6 months; no efficacy stopping\nAnalysis: logistic regression of remission on arm, adjusted for baseline ISI, in the intention-to-treat population; missing week-8 data counted as non-remission\nPrescan verdict: ready (0 errors, 0 warnings, 0 notes).\nFindings (rule | severity | line | text):\n- none"
}
Reply - the real saved reply the page shows for this sample, abbreviated: long strings are cut and end in ..., findings and questions keep only their first entry, design_review its first two and caveats its last (the full reply has 5 findings, 7 design-review topics, 7 questions and 4 caveats). prescan is empty because the page reported no findings to answer:
{
"task": "critique",
"verdict": "revise",
"summary": "This pivotal, assessor-blinded, 1:1 randomised trial tests whether the SleepPath digital CBT-I app produces more insomnia remission (ISI below 8) at week 8 than a sleep-hygiene education app with matched screen time. The page found no gaps and the core design ...",
"findings": [
{
"rule": "NEW",
"severity": "medium",
"title": "Sample size and missing-data rule assume different things",
"where": "Analysis",
"why": "The page's calculation powers 276 evaluable participants for 20% versus 35% and inflates to 345 for 20% dropout, which treats dropouts as missing ...",
"fix": "Define the estimand first (treatment-policy or composite with dropout as failure). Then either recompute the sample size with the diluted rates that ..."
}
],
"prescan": [],
"design_review": [
{
"topic": "Endpoint",
"assessment": "Remission defined as ISI below 8 at week 8 is specific, measurable, clinically meaningful and consistent across the synopsis, schedule and analysis ...",
"risk": "low"
},
{
"topic": "Control and blinding",
"assessment": "An attention-matched sleep-hygiene app is a reasonable comparator and assessor blinding is stated, but participant blinding rests on both apps being ...",
"risk": "medium"
}
],
"sample_size": "The page's two-proportion z-test (20% versus 35%, two-sided alpha 0.05, 80% power, 1:1, 20% dropout) needs 276 evaluable and 345 to enrol (173 per arm); 90% power needs 463 and a 10% smaller effect needs 420, and the planned 380 gives 83.7% power. The assumptions are plausible for an attention-controlled CBT-I trial but should ...",
"questions": [
{
"for": "biostatistics",
"question": "Which estimand is primary, and should the sample size be recalculated with the remission rates diluted by counting dropouts as non-remitters, or ..."
}
],
"caveats": [
"The sample-size calculation is preliminary and needs a biostatistician's review."
]
}
Every finding is NEW: the page could not see that the analysis counts missing week-8 data as non-remission while the sample size treats dropouts as lost, or that the suicidality screen has no response pathway. Sent to "draft", the same synopsis comes back as all eight sections with sample_size.enrol 345 and the week-8 timepoint throughout, and those two gaps raised as open questions.
Build the input yourself
The checker and the reply checker are plain scripts that run under node (global.window = global; require("./tpscan.js"); require("./tpreply.js")), then:
const S = TpScan, R = TpReply;
const st = { synopsis: fs.readFileSync("synopsis.txt", "utf8"), focus: "" };
const scan = S.scan(st); // findings, counts, design, timepoint, sampleSize, soa, verdict
console.log(S.ssLine(scan)); // the sample-size line that goes into facts
const b = S.buildInput(st, "critique"); // or "draft"; b.ok is false (with b.note) when the synopsis cannot run
const wire = S.toWire(b.input); // task, synopsis, focus, facts - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "critique");
// R.check(reply, b) // {notes, warn}: anything the reply missed or got wrong
// R.protocolText(reply) // a draft reply's sections as plain text
// R.handoff(reply) // a critique reply as the focus of a draft run
Get a token for the steps below from /tokens.html.
Step 1 - a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://trial-protocol-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://trial-protocol-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// trial-protocol-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://trial-protocol-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://trial-protocol-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// TrialProtocolDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class TrialProtocolDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://trial-protocol-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = +"YOUR_TOKEN" # from https://trial-protocol-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://trial-protocol-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://trial-protocol-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 - a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"trial-protocol-desk"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "trial-protocol-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "trial-protocol-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "trial-protocol-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "trial-protocol-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "trial-protocol-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "trial-protocol-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "trial-protocol-desk" }))!["token"]!.GetValue<string>();
Step 3 - GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 - POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save the worked-example input above as input.json (with task set to "critique" or "draft") to follow along.
# input.json: the run input object - {"task": "critique", "synopsis": "Title: ...\nIntervention: ...", "focus": "", "facts": "Title: ...\nSample size (page): ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "critique", "synopsis": "Title: ...", "focus": "", "facts": "Title: ...\nSample size (page): ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "critique" or "draft", synopsis, focus, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 - POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses trial-protocol-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a critique and a draft of the same synopsis are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. The code below parses the JSON object out of the output; for "critique" it prints the verdict, the summary, each design-review topic with its risk, the sample-size word and the questions, and for "draft" the summary, the enrolment and the eight sections as plain text.
KEY="trial-protocol-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "draft" then
.summary, "enrol: \(.sample_size.enrol)", .sample_size.statement,
(.sections // [] | to_entries[] | "", "\(.key + 1). \(.value.title | ascii_upcase)", "", .value.body)
else
"verdict: \(.verdict)", .summary,
(.design_review[]? | "\(.topic): \(.risk) - \(.assessment)"),
"Sample size: \(.sample_size)",
(.questions[]? | "[\(.for)] \(.question)")
end' reply.json # draft: the protocol sections as plain text
import hashlib, time
key = "trial-protocol-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
out = j["output"]["output"]
reply = json.loads(out[out.index("{"):out.rindex("}") + 1]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
if reply.get("task") == "draft":
print(reply.get("summary"))
ss = reply.get("sample_size", {})
print("enrol:", ss.get("enrol")) # the page's total to enrol, or None when it could not compute one
print(ss.get("statement"))
for i, s in enumerate(reply.get("sections", []), 1): # the protocol sections as plain text
print(f"\n{i}. {s['title'].upper()}\n\n{s['body']}")
else: # critique
print("verdict:", reply.get("verdict"))
print(reply.get("summary"))
for d in reply.get("design_review", []):
print(f"{d['topic']}: {d['risk']} - {d['assessment']}")
print("Sample size:", reply.get("sample_size"))
for q in reply.get("questions", []):
print(f"[{q['for']}] {q['question']}")
import { createHash } from "node:crypto";
const key = `trial-protocol-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const out = j.output.output;
const reply = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
console.log(j.charged_credits, j.truncated);
if (reply.task === "draft") {
console.log(reply.summary);
const ss = reply.sample_size || {};
console.log("enrol:", ss.enrol); // the page's total to enrol, or null when it could not compute one
console.log(ss.statement);
(reply.sections || []).forEach((s, i) => console.log(`\n${i + 1}. ${s.title.toUpperCase()}\n\n${s.body}`)); // the protocol sections as plain text
} else { // critique
console.log("verdict:", reply.verdict);
console.log(reply.summary);
for (const d of reply.design_review || []) console.log(`${d.topic}: ${d.risk} - ${d.assessment}`);
console.log("Sample size:", reply.sample_size);
for (const q of reply.questions || []) console.log(`[${q.for}] ${q.question}`);
}
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "trial-protocol-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
out := j["output"].(map[string]any)["output"].(string)
var reply map[string]any
json.Unmarshal([]byte(out[strings.Index(out, "{"):strings.LastIndex(out, "}")+1]), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
if reply["task"] == "draft" {
fmt.Println(reply["summary"])
ss, _ := reply["sample_size"].(map[string]any)
fmt.Println("enrol:", ss["enrol"]) // the page's total to enrol, or nil when it could not compute one
fmt.Println(ss["statement"])
secs, _ := reply["sections"].([]any)
for i, x := range secs { // the protocol sections as plain text
s := x.(map[string]any)
fmt.Printf("\n%d. %s\n\n%v\n", i+1, strings.ToUpper(fmt.Sprint(s["title"])), s["body"])
}
} else { // critique
fmt.Println("verdict:", reply["verdict"])
fmt.Println(reply["summary"])
dr, _ := reply["design_review"].([]any)
for _, x := range dr { d := x.(map[string]any); fmt.Printf("%v: %v - %v\n", d["topic"], d["risk"], d["assessment"]) }
fmt.Println("Sample size:", reply["sample_size"])
qs, _ := reply["questions"].([]any)
for _, x := range qs { q := x.(map[string]any); fmt.Printf("[%v] %v\n", q["for"], q["question"]) }
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "trial-protocol-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
String out = j.get("output").get("output").asText();
JsonNode reply = M.readTree(out.substring(out.indexOf('{'), out.lastIndexOf('}') + 1));
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
if ("draft".equals(reply.path("task").asText())) {
System.out.println(reply.path("summary").asText());
JsonNode ss = reply.path("sample_size");
System.out.println("enrol: " + ss.path("enrol")); // the page's total to enrol, or null when it could not compute one
System.out.println(ss.path("statement").asText());
int i = 0;
for (JsonNode s : reply.path("sections")) { // the protocol sections as plain text
System.out.println();
System.out.println(++i + ". " + s.path("title").asText().toUpperCase());
System.out.println();
System.out.println(s.path("body").asText());
}
} else { // critique
System.out.println("verdict: " + reply.path("verdict").asText());
System.out.println(reply.path("summary").asText());
for (JsonNode d : reply.path("design_review")) System.out.println(d.path("topic").asText() + ": " + d.path("risk").asText() + " - " + d.path("assessment").asText());
System.out.println("Sample size: " + reply.path("sample_size").asText());
for (JsonNode q : reply.path("questions")) System.out.println("[" + q.path("for").asText() + "] " + q.path("question").asText());
}
require "digest"
key = "trial-protocol-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
out = j["output"]["output"]
reply = JSON.parse(out[out.index("{")..out.rindex("}")])
puts [j["charged_credits"], j["truncated"]].inspect
if reply["task"] == "draft"
ss = reply["sample_size"] || {}
puts reply["summary"], "enrol: #{ss["enrol"].inspect}", ss["statement"] # enrol: the page's total, or nil
(reply["sections"] || []).each_with_index { |s, i| puts "", "#{i + 1}. #{s["title"].upcase}", "", s["body"] } # the protocol sections as plain text
else # critique
puts "verdict: #{reply["verdict"]}", reply["summary"]
(reply["design_review"] || []).each { |d| puts "#{d["topic"]}: #{d["risk"]} - #{d["assessment"]}" }
puts "Sample size: #{reply["sample_size"]}"
(reply["questions"] || []).each { |q| puts "[#{q["for"]}] #{q["question"]}" }
end
$key = "trial-protocol-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$out = $j["output"]["output"];
$from = strpos($out, "{");
$reply = json_decode(substr($out, $from, strrpos($out, "}") - $from + 1), true);
echo $j["charged_credits"], "\n";
if (($reply["task"] ?? "") === "draft") {
$ss = $reply["sample_size"] ?? [];
echo $reply["summary"] ?? "", "\n", "enrol: ", json_encode($ss["enrol"] ?? null), "\n", $ss["statement"] ?? "", "\n";
foreach ($reply["sections"] ?? [] as $i => $s) // the protocol sections as plain text
echo "\n", $i + 1, ". ", strtoupper($s["title"]), "\n\n", $s["body"], "\n";
} else { // critique
echo "verdict: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n";
foreach ($reply["design_review"] ?? [] as $d) echo $d["topic"], ": ", $d["risk"], " - ", $d["assessment"], "\n";
echo "Sample size: ", $reply["sample_size"] ?? "", "\n";
foreach ($reply["questions"] ?? [] as $q) echo "[", $q["for"], "] ", $q["question"], "\n";
}
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"trial-protocol-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var output = j["output"]!["output"]!.GetValue<string>();
var from = output.IndexOf('{');
var reply = JsonNode.Parse(output.Substring(from, output.LastIndexOf('}') - from + 1))!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
if ((string?)reply["task"] == "draft") {
Console.WriteLine(reply["summary"]);
var ss = reply["sample_size"];
var enrol = ss?["enrol"]?.ToJsonString() ?? "null"; // the page's total to enrol, or null
Console.WriteLine("enrol: " + enrol);
Console.WriteLine(ss?["statement"]);
var i = 0;
foreach (var s in reply["sections"]?.AsArray() ?? new JsonArray()) { // the protocol sections as plain text
Console.WriteLine();
Console.WriteLine($"{++i}. {((string?)s!["title"] ?? "").ToUpperInvariant()}");
Console.WriteLine();
Console.WriteLine(s["body"]);
}
} else { // critique
Console.WriteLine($"verdict: {reply["verdict"]}");
Console.WriteLine(reply["summary"]);
foreach (var d in reply["design_review"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"{d!["topic"]}: {d["risk"]} - {d["assessment"]}");
Console.WriteLine($"Sample size: {reply["sample_size"]}");
foreach (var q in reply["questions"]?.AsArray() ?? new JsonArray()) Console.WriteLine($"[{q!["for"]}] {q["question"]}");
}
Step 6 - POST /run-stream
The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.
# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: trial-protocol-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "trial-protocol-desk:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `trial-protocol-desk:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "trial-protocol-desk:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "trial-protocol-desk:" + M.readTree(rev).path("task").asText() + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "trial-protocol-desk:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, 443, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$task = json_decode($rev, true)["task"];
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: trial-protocol-desk:$task:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"trial-protocol-desk:{JsonNode.Parse(rev)!["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);