CDRI Questions (the question source)
Status:
betaCategory:compositeAdded:2026-08-12Last 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
answerson 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
promptworded for the audience you asked for, itsresponse_type, and the completeoptionslist. 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/extractand/cdri/computewithout 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_snapshotshifts on the first answer and every answer after it. - Read-only and deterministic apart from
order=random. No AI call.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/cdri/questions |
| Full URL | https://engine.tripod-health.com/api/cdri/questions |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | Yes, except order=random without a seed |
| Rate-limited | Yes |
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
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | application/json. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
answers | array | No | Every answer collected so far. Defaults to empty — an empty array is the opening call. |
answers[].item_id | string | Yes | The question's own id, for example pain_intensity_now. |
answers[].value | integer | Yes | A code from that question's options list. Not the label. |
answers[].answered_days_ago | integer | No | How long ago this answer was given. Omit for "today". |
context | object | No | De-identified descriptors. Never send identifiers — see conventions. |
context.age_bracket | string | No | For example "35-44". Never a date of birth. |
context.sex | string | No | |
context.occupation_class | string | No | |
context.naics_code | string | No | |
context.region | string | No | State 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
| Parameter | Type | Default | Description |
|---|---|---|---|
audience | enum | provider | provider, employee, employer. An allowlist, not a preference — see Audience. |
order | enum | urgency | urgency, voi, canonical, reverse, alphabetical, random. See Choosing an order. |
conversation_progress | number | 0 | 0–1. How far into the conversation you are. Gates eligibility — see The rapport floor. |
limit | integer | 1 | How many questions to return, up to 500. The response is always an array, even at one. |
role | enum | none | ptp, case_mgr, frontline, navigator. Ranking metadata for order=urgency. Never gates. |
exclude_items | CSV | none | item_ids to withhold. Plain ids, not qualified by anything. |
phenotype | enum | none | Keep only questions whose heaviest channel is the named one. |
response_type | enum | none | single_select, or one of the reserved types number, boolean, multi_select, free_text. |
snapshot | boolean | true | Set false to skip the risk readout. Does not change the price. |
seed | integer | none | Reproducible 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:
0means no concern.6means 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
0is still the benign end. - A question may expose only part of the ladder. A yes/no question uses the
two ends — codes
0and6— and nothing between them.new_neuro_symptomsoffers 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.
| Audience | Questions available | What it is |
|---|---|---|
provider | 107 of 107 | The clinician-facing wording. Nothing is withheld. |
employee | 98 of 107 | Asked of the injured person directly. |
employer | 21 of 107 | Asked 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, not1. 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
0the bank withholds 92 questions; the response says how many innotes. - 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 andconversation_progressis inert. The response notes this.
Choosing an order
Among everything eligible, order chooses. The two gates above always win
first.
| Order | Ranks by | Use it when |
|---|---|---|
urgency | How early the answer is needed, modulated by role. | Default, and the right one for a real conversation. |
voi | The confidence each question buys, greedily. | You want evidence spread across the seven channels rather than piled on one. |
canonical | The bank's own sim_order. | Reviewing the bank. Not for interviewing — see below. |
reverse | canonical, reversed. | Probing for order effects. |
alphabetical | item_id. | Deterministic traversal in tests. |
random | Nothing. Shuffles. | Probing the surface. Not for administration. |
Two things worth knowing:
voiis 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.canonicalasks near-duplicates back to back.sim_orderis 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
| Field | Type | Always present | Description |
|---|---|---|---|
questions | array | Yes | The questions to render, in the requested order. Empty when the pool is exhausted. |
questions[].item_id | string | Yes | The question's id. Send it back verbatim in answers. |
questions[].prompt | string | Yes | Display text, worded for the requested audience. See Item text caveat. |
questions[].response_type | string | Yes | single_select for every question in the current bank. |
questions[].options | array | Yes | Complete {value, label} list. Send value; display label. May be a two-entry subset of the ladder. |
questions[].urgency | number | Yes | 0–1. How early this answer is needed. What order=urgency ranks on. |
questions[].rapport_floor | number | Yes | 0–1. The conversation_progress this question becomes eligible at. Reported for every audience, but not applied for employer. |
questions[].reask_urgency | number | Yes | 0–1. How much value there is in asking this again later — 1 on a question whose answer moves day to day. |
count | integer | Yes | questions.length. |
order / audience / limit / conversation_progress | Yes | The resolved configuration, echoed back. | |
role | string | null | Yes | null unless you sent one. |
seed | integer | null | Yes | null unless order=random. |
questions_available | integer | Yes | Eligible questions in the pool before this call's were taken. |
questions_remaining | integer | Yes | Eligible questions left after them. |
exhausted | boolean | Yes | true when this call emptied the eligible pool. |
coverage | object | Yes | See below. |
risk_snapshot | object | null | Yes | null when snapshot=false. See below. |
notes | array of string | Yes | Plain-language warnings about the configuration you chose. Surface them in a developer console; do not show them to an end user. |
catalog_version | string | Yes | Catalog version that served the request. |
bank_version | string | Yes | The question bank version behind every figure here. |
request_id | string | Yes | Correlation id. |
timestamp | string | Yes | ISO 8601 UTC. |
coverage
| Field | Type | Description |
|---|---|---|
bank_version | string | The active bank version. |
active_questions | integer | Questions in the bank. 107 today. |
askable_now | integer | Eligible after both gates and your filters. |
withheld_from_audience | integer | Questions 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,breadthandconfidenceare all absent on the opening call, andphenotypesis{}. There is nothing measured yet, and a0would claim otherwise. - This
confidenceis not the same number/cdri/computepublishes. Here only theevidenceterm fires, because the planning path runs no AI and no imputation. On/cdri/computethe same evidence value is renormalized againstimputation_varianceandmodel_certainty, so the headline reads differently for identical answers — for one measured answer set,0.4874here against0.4587there, withevidenceidentical to the digit. Comparebreakdown.evidence, neveroverall.
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Questions returned. An empty questions array with exhausted: true is a 200, not an error. |
400 | ValidationError | Malformed 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. |
400 | DeIdentificationViolation | The body carried something that looks like an identifier. field names the path. See conventions. |
401 | Unauthenticated | Missing or invalid API key. |
402 | PaymentRequired | Insufficient credits. See credits. |
429 | RateLimited | Rate limit exceeded. |
503 | ServiceUnavailable | The 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=employerfor the employer's side of the same case. - Reviewing the bank.
order=canonicalwith a largelimitwalks it.
Mistaken use
- Do not send
options[].labelback as avalue. Labels are for display; the integer code is the contract. - Do not assume all seven rungs exist. Read
optionsper question. A yes/no question offers0and6only. - Do not leave
conversation_progressat0for 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=randomororder=canonicalfor real administration.randomscatters across channels and buys much less confidence for the same number of questions;canonicalasks near-duplicates consecutively. - Do not treat
risk_snapshotas a final result. It runs no AI and no Layer 4./cdri/computeis the scored result, and it is the one that writes the corpus row. - Do not expect an instrument here. There is no
instrumentsparameter and noinstrument_idon an answer. To score a published instrument, use its own/scoreendpoint; the two surfaces do not mix.
Debugging
- Capture
request_idfrom the response body (or, if the response has no body from the engine, theX-Request-IDresponse header). questionscame back empty. Readcoverage.askable_now: 0with a non-zerowithheld_from_audiencemeans the audience cannot be asked what your filters left; a lowconversation_progressis the other usual cause. Checknotesfirst — it names whichever gate did it.- A
400names a field you did not send. Readfield. An answer object carrying anything beyonditem_id,valueandanswered_days_agois rejected, and the rejection names the extra key. - A value was rejected. The message lists that question's legal codes. They
are not always
0–6. order=voireturns something urgency would not. Expected.voiignores urgency entirely and ranks by information gained.- A
503. The bank did not load. It is not your request; retry, and report it with therequest_idif it persists.
Rate limits
See rate-limits.md.
Related
- composite-cdri-compute.md — post the answers here to get the scored result.
- composite-cdri-extract.md — the third face of the CDRI surface: answers mined from a visit transcript, in this same shape.
- composite-phenotype-classify.md — the pattern and its recommendations, without the scoring envelope.
- patterns-score.md — scoring a published instrument, which is a separate surface from this bank.
- conventions.md — the de-identification boundary.
Change history
| Date | Change |
|---|---|
| 2026-08-12 | Initial publication. Replaces the per-instrument GET /api/:category/:resource/questions operation and the retired /api/cdri/plan and /api/intake/next endpoints. |
| 2026-09-16 | Rewritten 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-14 | The 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. |