Loading documentation

Access required

Your account doesn't have access to the documentation. Contact your administrator.

Sign out
Skip to main content

CDRI Questions (the question source)

Status: beta Category: composite Added: 2026-08-12 Last reviewed: 2026-09-16

Summary​

POST /api/cdri/questions returns render-ready questions from the CDRI question bank, in the order you ask for. It is the read half of the CDRI surface: ask here, post the answers to /cdri/compute in the same array shape, unchanged.

The bank is the only source. Every question is identified by its own item_id and answered with a code on a 0–6 severity ladder. There are no instruments on this endpoint — the published assessment instruments are a separate group with their own /score endpoints, and they share no identifiers and no inputs with this bank.

How it works (for integrators)​

  • Stateless per turn. The engine holds no session. You carry the state: re-send the accumulated answers on every call, and those questions leave the pool. Nothing is stored and no corpus row is written.
  • Every question is render-ready on its own. Each carries its prompt worded for the audience you asked for, its response_type, and the complete options list. You never need a second call to draw one.
  • One answer shape across three endpoints. An {item_id, value} object moves between /cdri/questions, /cdri/extract and /cdri/compute without being reshaped.
  • The index moves on every answer. Because scoring is per question rather than per instrument, there is no floor to cross before numbers appear — the risk_snapshot shifts on the first answer and every answer after it.
  • Read-only and deterministic apart from order=random. No AI call.

Endpoint​

FieldValue
MethodPOST
Path/api/cdri/questions
Full URLhttps://engine.tripod-health.com/api/cdri/questions
AuthenticationAPI key (required)
Request bodyapplication/json
Response bodyapplication/json
IdempotentYes, except order=random without a seed
Rate-limitedYes

Authentication​

Every request must include a valid API key in the apikey header. See authentication. A missing or unknown key returns 401 at the gateway before the request reaches the engine.

Request​

Headers​

HeaderRequiredDescription
apikeyYesConsumer API key.
Content-TypeYesapplication/json.

Body​

FieldTypeRequiredDescription
answersarrayNoEvery answer collected so far. Defaults to empty — an empty array is the opening call.
answers[].item_idstringYesThe question's own id, for example pain_intensity_now.
answers[].valueintegerYesA code from that question's options list. Not the label.
answers[].answered_days_agointegerNoHow long ago this answer was given. Omit for "today".
contextobjectNoDe-identified descriptors. Never send identifiers — see conventions.
context.age_bracketstringNoFor example "35-44". Never a date of birth.
context.sexstringNo
context.occupation_classstringNo
context.naics_codestringNo
context.regionstringNoState or region, never a street address.
{
"answers": [
{ "item_id": "pain_intensity_now", "value": 5 },
{ "item_id": "difficulty_walking", "value": 4 }
],
"context": { "age_bracket": "35-44", "occupation_class": "medium" }
}

Query parameters​

ParameterTypeDefaultDescription
audienceenumproviderprovider, employee, employer. An allowlist, not a preference — see Audience.
orderenumurgencyurgency, voi, canonical, reverse, alphabetical, random. See Choosing an order.
conversation_progressnumber00–1. How far into the conversation you are. Gates eligibility — see The rapport floor.
limitinteger1How many questions to return, up to 500. The response is always an array, even at one.
roleenumnoneptp, case_mgr, frontline, navigator. Ranking metadata for order=urgency. Never gates.
exclude_itemsCSVnoneitem_ids to withhold. Plain ids, not qualified by anything.
phenotypeenumnoneKeep only questions whose heaviest channel is the named one.
response_typeenumnonesingle_select, or one of the reserved types number, boolean, multi_select, free_text.
snapshotbooleantrueSet false to skip the risk readout. Does not change the price.
seedintegernoneReproducible shuffle for order=random. Ignored by every other order.

The answer ladder​

Every question in the bank is a single_select on one shared 0–6 severity ladder, and the direction never varies:

  • 0 means no concern. 6 means as bad as that question goes.
  • A question worded so that agreement is the good answer lists its labels in reverse rather than carrying a direction flag, so 0 is still the benign end.
  • A question may expose only part of the ladder. A yes/no question uses the two ends — codes 0 and 6 — and nothing between them. new_neuro_symptoms offers exactly {0: "No", 6: "Yes"}.

Read options off the question and send one of its value codes. Never hardcode a range, and never assume all seven rungs are present.

Audience​

audience is an allowlist gated on the bank's own safety columns, not a formatting preference. A question not explicitly marked safe for that audience is withheld, and the wording you get is the wording curated for that audience.

AudienceQuestions availableWhat it is
provider107 of 107The clinician-facing wording. Nothing is withheld.
employee98 of 107Asked of the injured person directly.
employer21 of 107Asked of the employer, about the job.

The employer set is deliberately narrow: an employer is asked only what the employment relationship gives them first-hand knowledge of — modified duty, restrictions, job demands, return-to-work planning — and never about pain, mood, sleep, medication or medical history.

coverage.withheld_from_audience reports how many questions the audience may not see, and the response says so in notes. Expect thinner evidence on the channels those questions carry.

:::note clinician is not a valid value The audience vocabulary is provider, employee, employer across the whole engine, including /cdri/compute. One audience value carries across the surface. clinician returns 400. :::

The rapport floor​

Every question carries a rapport_floor, and conversation_progress is compared against it. A floor gates eligibility, not placement: a question below its floor is not a candidate at all, however urgent it is.

That is what stops an urgency ranking from opening a cold conversation with the self-harm item, which carries the highest urgency in the bank and a floor of 0.700.

  • The default is 0, not 1. A caller who does not report progress gets the fifteen questions askable in an opening line — pain intensity, the neurological red flag, and the basic function items.
  • Raise it as the conversation proceeds. At 0 the bank withholds 92 questions; the response says how many in notes.
  • It has no effect for audience=employer. A rapport floor measures what the injured person may be asked. The employer answers factual questions about a job, so all 21 employer-askable questions are available on the first call and conversation_progress is inert. The response notes this.

Choosing an order​

Among everything eligible, order chooses. The two gates above always win first.

OrderRanks byUse it when
urgencyHow early the answer is needed, modulated by role.Default, and the right one for a real conversation.
voiThe confidence each question buys, greedily.You want evidence spread across the seven channels rather than piled on one.
canonicalThe bank's own sim_order.Reviewing the bank. Not for interviewing — see below.
reversecanonical, reversed.Probing for order effects.
alphabeticalitem_id.Deterministic traversal in tests.
randomNothing. Shuffles.Probing the surface. Not for administration.

Two things worth knowing:

  • voi is the escape hatch from the role preference. It promotes exactly the channel that has gone unevidenced, so a batch spreads out instead of deepening whichever channel is already best covered. Gains are submodular, so asking one question at a time still re-ranks sensibly — there is no batch you have to finish.
  • canonical asks near-duplicates back to back. sim_order is a walk through the bank's semantic space that deliberately puts similar questions adjacent, so a curator sees redundancy as a group. Walking it asks the closest thing to the same question twice in a row.

role ranks and never gates: a question a role cares little about comes last rather than not at all. It affects ordering only — no risk figure changes with role.

Response​

Success — 200 OK​

The opening call, ?limit=2:

{
"questions": [
{
"item_id": "new_neuro_symptoms",
"prompt": "Have new neurological symptoms - numbness, tingling, or weakness - appeared since the injury?",
"response_type": "single_select",
"urgency": 0.98,
"rapport_floor": 0,
"reask_urgency": 0.82,
"options": [
{ "value": 0, "label": "No" },
{ "value": 6, "label": "Yes" }
]
},
{
"item_id": "pain_intensity_now",
"prompt": "How does the patient rate their pain right now?",
"response_type": "single_select",
"urgency": 0.94,
"rapport_floor": 0,
"reask_urgency": 1,
"options": [
{ "value": 0, "label": "None" },
{ "value": 1, "label": "Very mild" },
{ "value": 2, "label": "Mild" },
{ "value": 3, "label": "Moderate" },
{ "value": 4, "label": "Marked" },
{ "value": 5, "label": "Severe" },
{ "value": 6, "label": "Worst possible" }
]
}
],
"count": 2,
"order": "urgency",
"audience": "provider",
"role": null,
"conversation_progress": 0,
"limit": 2,
"seed": null,
"questions_available": 15,
"questions_remaining": 13,
"exhausted": false,
"coverage": {
"bank_version": "2026-09-15-standard-ladder",
"active_questions": 107,
"askable_now": 15,
"withheld_from_audience": 0
},
"risk_snapshot": {
"cdri": {
"score": null,
"band": null,
"bank_version": "2026-09-15-standard-ladder"
},
"phenotypes": {},
"breadth": null,
"confidence": null
},
"notes": [
"conversation_progress=0.0 withholds 92 question(s) whose rapport_floor has not been reached. Raise it as the conversation proceeds; a floor gates eligibility, so an urgent question below its floor is not a candidate at all."
],
"catalog_version": "0.36.0",
"bank_version": "2026-09-15-standard-ladder",
"request_id": "e3e3e203-a88b-48e5-9f6b-b78ba3649700",
"timestamp": "2026-09-16T01:05:23.626530+00:00"
}

Response fields​

FieldTypeAlways presentDescription
questionsarrayYesThe questions to render, in the requested order. Empty when the pool is exhausted.
questions[].item_idstringYesThe question's id. Send it back verbatim in answers.
questions[].promptstringYesDisplay text, worded for the requested audience. See Item text caveat.
questions[].response_typestringYessingle_select for every question in the current bank.
questions[].optionsarrayYesComplete {value, label} list. Send value; display label. May be a two-entry subset of the ladder.
questions[].urgencynumberYes0–1. How early this answer is needed. What order=urgency ranks on.
questions[].rapport_floornumberYes0–1. The conversation_progress this question becomes eligible at. Reported for every audience, but not applied for employer.
questions[].reask_urgencynumberYes0–1. How much value there is in asking this again later — 1 on a question whose answer moves day to day.
countintegerYesquestions.length.
order / audience / limit / conversation_progressYesThe resolved configuration, echoed back.
rolestring | nullYesnull unless you sent one.
seedinteger | nullYesnull unless order=random.
questions_availableintegerYesEligible questions in the pool before this call's were taken.
questions_remainingintegerYesEligible questions left after them.
exhaustedbooleanYestrue when this call emptied the eligible pool.
coverageobjectYesSee below.
risk_snapshotobject | nullYesnull when snapshot=false. See below.
notesarray of stringYesPlain-language warnings about the configuration you chose. Surface them in a developer console; do not show them to an end user.
catalog_versionstringYesCatalog version that served the request.
bank_versionstringYesThe question bank version behind every figure here.
request_idstringYesCorrelation id.
timestampstringYesISO 8601 UTC.

coverage​

FieldTypeDescription
bank_versionstringThe active bank version.
active_questionsintegerQuestions in the bank. 107 today.
askable_nowintegerEligible after both gates and your filters.
withheld_from_audienceintegerQuestions this audience may never be asked.

The three counts deliberately do not reconcile by subtraction — the rapport floor, your filters and the audience allowlist overlap. A caller subtracting them would conclude questions had gone missing.

risk_snapshot​

Present unless snapshot=false. The deterministic, AI-free readout over the answers you sent, so a consumer can animate channel bars and a confidence meter as answers arrive. Same shape as the corresponding blocks on /cdri/compute, and scored in the same pass, so the two can never disagree about the same answers.

{
"cdri": {
"score": 76.72,
"band": "critical",
"bank_version": "2026-09-15-standard-ladder"
},
"phenotypes": {
"pain_dominant": { "level": 0.8267, "evidence": 0.7124, "residual": 0.0271 }
},
"breadth": 0.9983,
"confidence": {
"overall": 0.4874,
"tier": "medium",
"breakdown": {
"evidence": 0.48739895826606555,
"input_completeness": null,
"imputation_variance": null,
"model_certainty": null
}
}
}

phenotypes is abbreviated above to one channel for readability; a real response carries all seven.

Two things to expect:

  • With no answers, everything is null. score, band, breadth and confidence are all absent on the opening call, and phenotypes is {}. There is nothing measured yet, and a 0 would claim otherwise.
  • This confidence is not the same number /cdri/compute publishes. Here only the evidence term fires, because the planning path runs no AI and no imputation. On /cdri/compute the same evidence value is renormalized against imputation_variance and model_certainty, so the headline reads differently for identical answers — for one measured answer set, 0.4874 here against 0.4587 there, with evidence identical to the digit. Compare breakdown.evidence, never overall.

Status codes​

CodeMeaningWhen
200OKQuestions returned. An empty questions array with exhausted: true is a 200, not an error.
400ValidationErrorMalformed body; an unknown order, audience, phenotype or response_type; a limit outside 1–500; an exclude_items entry that is not in the bank; or an answer naming an unknown question or carrying a code that question does not offer. field names the offender.
400DeIdentificationViolationThe body carried something that looks like an identifier. field names the path. See conventions.
401UnauthenticatedMissing or invalid API key.
402PaymentRequiredInsufficient credits. See credits.
429RateLimitedRate limit exceeded.
503ServiceUnavailableThe question bank could not be loaded. Retry; nothing about your request caused it.

Examples​

The invocations below assume the call() helper from quickstart.md.

1. The opening call​

curl -sS -X POST \
"https://engine.tripod-health.com/api/cdri/questions?limit=5" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"answers": []}'

Five questions from the fifteen askable cold, led by the neurological red flag and pain intensity.

2. Driving a session​

# Uses the call() helper from quickstart.md.
answers = []
progress = 0.0

while True:
turn = call(
"POST",
f"/api/cdri/questions?limit=5&audience=employee"
f"&conversation_progress={progress}",
{"answers": answers},
)
if not turn["questions"]:
break

for question in turn["questions"]:
value = ask(question) # your UI
answers.append({
"item_id": question["item_id"], # copy, do not reshape
"value": value,
})

# Raise progress as rapport builds; it unlocks the more sensitive questions.
progress = min(1.0, progress + 0.1)
print(turn["risk_snapshot"]["cdri"]["score"], turn["questions_remaining"])

result = call("POST", "/api/cdri/compute", {"answers": answers})

The loop terminates on an empty questions array. exhausted tells you the same thing one turn earlier, if you want to show "last question".

3. Spreading evidence instead of deepening one channel​

// Uses the call() helper from quickstart.md.
const turn = await call(
"POST",
"/api/cdri/questions?order=voi&limit=5&conversation_progress=0.4",
{ answers: collectedSoFar },
);

voi ranks by the confidence each question buys, so this batch reaches across the seven channels rather than adding a sixth pain question.

4. Asking the employer​

curl -sS -X POST \
"https://engine.tripod-health.com/api/cdri/questions?audience=employer&limit=5&snapshot=false" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"answers": []}'

All 21 employer-askable questions are available immediately; conversation_progress is not needed and has no effect.

5. Not re-offering a skipped question​

curl -sS -X POST \
"https://engine.tripod-health.com/api/cdri/questions?limit=3&exclude_items=hopping,running_even_ground" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"answers": [{"item_id": "pain_intensity_now", "value": 5}]}'

answers removes questions because they are answered. exclude_items removes questions you asked and did not get an answer to — a skipped question, a timed-out turn — and do not want offered again.

Item text caveat​

prompt is the bank's own curated wording, authored for each audience. Where a question corresponds to an item in a published, licensed instrument, the bank carries a paraphrase rather than the licensed text. Before showing these strings to a real user, confirm your own licensing position for anything you recognise. Trinity treats the value codes as the authoritative contract, not the displayed text.

Intended use​

  • Driving a questionnaire UI. One endpoint, one loop, no per-question bookkeeping beyond the accumulating array.
  • Gamified or progressive intake. The snapshot moves on every answer, so a progress surface always has something honest to animate.
  • Resuming a partial session. Send back what you have; the pool reflects it.
  • Asking the right party. Run the same loop against audience=employer for the employer's side of the same case.
  • Reviewing the bank. order=canonical with a large limit walks it.

Mistaken use​

  • Do not send options[].label back as a value. Labels are for display; the integer code is the contract.
  • Do not assume all seven rungs exist. Read options per question. A yes/no question offers 0 and 6 only.
  • Do not leave conversation_progress at 0 for a whole session — you will never be offered the 92 questions above the opening floor. Raise it as rapport builds. It is not a progress bar for the caller's benefit; it is a gate.
  • Do not use order=random or order=canonical for real administration. random scatters across channels and buys much less confidence for the same number of questions; canonical asks near-duplicates consecutively.
  • Do not treat risk_snapshot as a final result. It runs no AI and no Layer 4. /cdri/compute is the scored result, and it is the one that writes the corpus row.
  • Do not expect an instrument here. There is no instruments parameter and no instrument_id on an answer. To score a published instrument, use its own /score endpoint; the two surfaces do not mix.

Debugging​

  1. Capture request_id from the response body (or, if the response has no body from the engine, the X-Request-ID response header).
  2. questions came back empty. Read coverage. askable_now: 0 with a non-zero withheld_from_audience means the audience cannot be asked what your filters left; a low conversation_progress is the other usual cause. Check notes first — it names whichever gate did it.
  3. A 400 names a field you did not send. Read field. An answer object carrying anything beyond item_id, value and answered_days_ago is rejected, and the rejection names the extra key.
  4. A value was rejected. The message lists that question's legal codes. They are not always 0–6.
  5. order=voi returns something urgency would not. Expected. voi ignores urgency entirely and ranks by information gained.
  6. A 503. The bank did not load. It is not your request; retry, and report it with the request_id if it persists.

Rate limits​

See rate-limits.md.

Change history​

DateChange
2026-08-12Initial publication. Replaces the per-instrument GET /api/:category/:resource/questions operation and the retired /api/cdri/plan and /api/intake/next endpoints.
2026-09-16Rewritten for the question bank. Questions are bank item_ids on one 0–6 ladder; instruments, domain and the instrument_id on an answer are gone. Adds audience=provider|employee|employer (replacing clinician/patient), conversation_progress and the rapport floor, role, and order=urgency as the default; removes order=completion_greedy and voi's instrument-batch behaviour. coverage and risk_snapshot reshaped.
2026-10-14The engine assigns every request ID; an X-Request-ID request header is not used. The X-Request-ID response header is the gateway's trace ID for the request and does not match request_id; quote request_id to support.