Adaptive Intake
Status:
stableCategory:compositeAdded:2026-06-03Last reviewed:2026-07-12
Summary
Engine-driven adaptive intake, the collection counterpart to
/cdri/plan. You send the full set of answers
collected so far; the engine ranks the remaining question batches by
value-of-information and returns the next questions to ask — dripped from the
single highest-value instrument, in order — until the plan is exhausted. Every
turn also returns a deterministic, AI-free risk_snapshot (CDRI score, the
seven phenotype magnitudes, and confidence) so you can show a live,
moving-as-they-answer risk readout without running the full CDRI on each turn.
/cdri/plan answers "what should I ask?" as a whole ranked map;
/intake/next drives the asking and the collection one turn at a time and
adds the live risk feedback. Both sit on the same deterministic scoring math.
How it works (for integrators)
- Stateless. There is no per-subject session. On every call you re-send
every answer collected so far in
answers; the engine re-evaluates the whole set. Store answers on your side (see Storing answers). - The loop. Call
/intake/next→ renderquestions→ collect answers → append them toanswers→ call again. Repeat until the response comes backphase: "complete". In a waiting-room flow you simply stop early whenever the session ends (e.g. a clinician picks up) — the answers are already stored. - Value-of-information order. Questions come from the highest-value
instrument first (the same greedy math as
/cdri/plan), so a limited number of questions covers the most decision-relevant ground. - Item drip. A turn returns questions from one instrument at a time
(never mixed).
?limitcaps how many of that instrument's items come back per turn; the default is the whole remaining batch. - Live risk snapshot.
risk_snapshotreflects the answers so far. Because an instrument only contributes to the CDRI once all its required items are answered, the snapshot moves when an instrument completes, not on every keystroke — single-item instruments move immediately; a long instrument moves when its last required item lands. - Completion. The engine returns
phase: "complete"when no instrument is left worth asking (reason: "plan_exhausted"), or when a filter matches nothing (see Filters). - Read-only. Intake does not run the AI layers and does not persist a
training record. Persist the scored record later by submitting the collected
answers to
/cdri/compute.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/intake/next |
| Full URL | https://engine.tripod-health.com/api/intake/next |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | Yes (deterministic; no model-generated fields) |
| 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 Unauthenticated at the gateway.
Request
Query parameters
All optional. They narrow or pace the turn; omit them for the default flow.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | whole batch | Max questions to return this turn (the drip size). Questions always come from a single instrument, so limit caps items within that instrument. Must be a positive integer. |
domain | string | — | Restrict to one CDRI domain: pain_load, psychosocial_load, function_load, rtw_load, or iatrogenic_load. |
phenotype | string | — | Restrict to instruments that inform a phenotype (e.g. iatrogenic_escalation), via the phenotype matrix. |
response_type | string | — | Restrict to instruments whose items render as this type. Supported today: number, single_select. boolean, multi_select, and free_text are reserved — no instrument currently emits them, so filtering on one returns phase: "complete" with reason: "reserved_response_type". |
Body
| Field | Type | Required | Description |
|---|---|---|---|
answers | array | Yes | Every answer collected so far. Empty array [] on the first call. Each entry is { "instrument_id", "item_id", "value" }. Re-send the full list on every call. |
context | object | null | No | Structural descriptors for the subject (age_bracket, sex, occupation_class, naics_code, region) — the same shape as demographics elsewhere. Improves cohort context for the confidence/snapshot math. |
{
"answers": [],
"context": { "age_bracket": "45-54", "sex": "male", "occupation_class": "construction", "region": "OH" }
}
A later turn re-sends prior answers and appends new ones:
{
"answers": [
{ "instrument_id": "pain_scale", "item_id": "item_1", "value": 8 }
],
"context": { "age_bracket": "45-54", "sex": "male", "occupation_class": "construction", "region": "OH" }
}
De-identification
/intake/next is subject to the same de-identification filter as every
endpoint — an identifier-shaped value anywhere in the body returns
400 DeIdentificationViolation. Send structural descriptors only.
Response
The response always carries a phase discriminator: "ask", "complete", or
"error". Branch on it. progress and risk_snapshot are present on both
ask and complete.
phase: "ask" — 200 OK
Verbatim production capture of the first call (empty answers, ?limit=3).
The engine selected pain_scale (the cheapest way to cover the highest-value
uncovered domain) and returned its single item. risk_snapshot is all-zero
because nothing is scored yet.
{
"phase": "ask",
"batch": {
"instrument_id": "pain_scale",
"instrument_version": "1.0.0",
"domain": "pain_load",
"rank": 1,
"response_type": "number",
"marginal_confidence_gain": 0.3571,
"projected_confidence_after": 0.5714,
"projected_tier_after": "medium",
"informs_phenotypes": ["established_chronicity", "multi_domain_catastrophic", "pain_dominant"],
"items_remaining_in_instrument": 1,
"drip_limit": 3
},
"questions": [
{
"instrument_id": "pain_scale",
"item_id": "item_1",
"prompt": "Pain intensity right now (0 = no pain, 10 = worst imaginable)",
"required": true,
"response_type": "number",
"min": 0,
"max": 10,
"instrument_rank": 1,
"item_index": 1,
"item_count_required": 1
}
],
"progress": {
"domains_required": ["pain_load", "psychosocial_load", "function_load", "rtw_load"],
"domains_covered": [],
"instruments_complete": [],
"instruments_partial": [],
"plan_remaining_instruments": 4
},
"risk_snapshot": {
"cdri_score": 0,
"overall_magnitude": 0,
"magnitudes": { "pain_dominant": 0, "psychosocial_dominant": 0, "fear_avoidance_dominant": 0, "rtw_barrier": 0, "multi_domain_catastrophic": 0, "iatrogenic_escalation": 0, "established_chronicity": 0 },
"confidence": { "overall": 0.2143, "tier": "low", "breakdown": { "input_completeness": 0, "imputation_variance": 0.5, "model_certainty": null } },
"matrix_version": "2026-06-02-first-pass"
},
"catalog_version": "0.22.0",
"matrix_version": "2026-06-02-first-pass",
"request_id": "385e24e7-948f-441a-a4c5-f1b7f4330868",
"timestamp": "2026-07-12T23:12:14.983027+00:00"
}
Watch the snapshot move. Answering that one pain_scale item (value: 8)
and re-posting completes the pain instrument, so the next turn advances to
recovery_expectation and the risk bars jump — verbatim batch +
risk_snapshot from that turn:
{
"batch": {
"instrument_id": "recovery_expectation",
"domain": "rtw_load",
"rank": 1,
"response_type": "number",
"marginal_confidence_gain": 0.1429,
"projected_confidence_after": 0.7143,
"projected_tier_after": "medium",
"items_remaining_in_instrument": 1
},
"progress": { "domains_covered": ["pain_load"], "instruments_complete": ["pain_scale"], "plan_remaining_instruments": 3 },
"risk_snapshot": {
"cdri_score": 80,
"overall_magnitude": 0.8,
"magnitudes": { "pain_dominant": 0.8, "psychosocial_dominant": 0, "fear_avoidance_dominant": 0, "rtw_barrier": 0, "multi_domain_catastrophic": 0.8, "iatrogenic_escalation": 0, "established_chronicity": 0.8 },
"confidence": { "overall": 0.5714, "tier": "medium", "breakdown": { "input_completeness": 0.25, "imputation_variance": 1, "model_certainty": null } }
}
}
Response fields — ask
| Field | Type | Description |
|---|---|---|
phase | string | "ask". |
batch | object | Metadata for the instrument this turn's questions belong to (see below). All questions in a turn belong to this one instrument. |
questions | array | Render-ready items: instrument_id, item_id, prompt, required, response_type, min/max (number) or options (single_select), plus instrument_rank, item_index, item_count_required. Same item shape as /questions. |
progress | object | domains_required, domains_covered, instruments_complete, instruments_partial, plan_remaining_instruments. Drive a progress bar from this. |
risk_snapshot | object | Live deterministic risk over the answers so far (see Risk snapshot). |
catalog_version, matrix_version | string | Versions served. |
request_id | string | Correlation ID. |
timestamp | string (ISO 8601 UTC) | Server time. |
batch carries: instrument_id, instrument_version, domain, rank (its
VOI rank this turn — always 1, since the drip is from the top instrument),
response_type, marginal_confidence_gain and projected_confidence_after /
projected_tier_after (the confidence completing this instrument buys),
informs_phenotypes, items_remaining_in_instrument, and drip_limit (echo of
?limit).
The risk snapshot
risk_snapshot is the deterministic, AI-free CDRI readout over the answers
collected so far — the same math /cdri/compute
runs, minus the AI layers, so it is fast enough to return on every turn.
| Field | Type | Description |
|---|---|---|
cdri_score | number | 0–100 peak headline (100 × the top phenotype magnitude). |
overall_magnitude | number | The peak magnitude, 0–1. |
magnitudes | object | The seven phenotype magnitudes, each 0–1 — the "bars." |
confidence | object | overall + tier + per-term breakdown. model_certainty is always null here (an AI-layer signal, not run during intake). |
matrix_version | string | Phenotype-matrix version behind the magnitudes. |
It reflects the actual answers, so it only moves when an instrument completes (the scoring cliff above). A partially-answered instrument contributes nothing until its last required item lands. The engine never fabricates per-item movement.
phase: "complete" — 200 OK
Returned when the plan is exhausted (every worthwhile instrument answered). Verbatim production capture after all four required domains were covered:
{
"phase": "complete",
"reason": "plan_exhausted",
"progress": {
"domains_required": ["pain_load", "psychosocial_load", "function_load", "rtw_load"],
"domains_covered": ["function_load", "pain_load", "psychosocial_load", "rtw_load"],
"instruments_complete": ["anxiety_screen", "oswestry", "pain_scale", "recovery_expectation"],
"instruments_partial": [],
"plan_remaining_instruments": 0
},
"risk_snapshot": {
"cdri_score": 80,
"overall_magnitude": 0.8,
"magnitudes": { "pain_dominant": 0.8, "psychosocial_dominant": 0.5, "fear_avoidance_dominant": 0, "rtw_barrier": 0.8, "multi_domain_catastrophic": 0.737, "iatrogenic_escalation": 0, "established_chronicity": 0.8 },
"confidence": { "overall": 1, "tier": "high", "breakdown": { "input_completeness": 1, "imputation_variance": 1, "model_certainty": null } }
},
"catalog_version": "0.22.0",
"matrix_version": "2026-06-02-first-pass",
"request_id": "48ea4115-49a0-47f0-a7c1-457bddbd76f7",
"timestamp": "2026-07-12T23:12:51.827428+00:00"
}
reason is one of: plan_exhausted (nothing left worth asking),
no_candidates_for_filter (an active filter matched no instrument), or
reserved_response_type (filtered on a reserved type — carries a notes line).
On plan_exhausted, submit the collected answers to
/cdri/compute for the full scored result.
A reserved_response_type completion (verbatim, ?response_type=boolean):
{
"phase": "complete",
"reason": "reserved_response_type",
"notes": ["response_type 'boolean' is reserved; no instrument currently emits it."],
"progress": { "domains_required": ["pain_load", "psychosocial_load", "function_load", "rtw_load"], "domains_covered": [], "instruments_complete": [], "instruments_partial": [], "plan_remaining_instruments": 0 },
"risk_snapshot": { "cdri_score": 0, "overall_magnitude": 0, "magnitudes": { "pain_dominant": 0, "psychosocial_dominant": 0, "fear_avoidance_dominant": 0, "rtw_barrier": 0, "multi_domain_catastrophic": 0, "iatrogenic_escalation": 0, "established_chronicity": 0 }, "confidence": { "overall": 0.2143, "tier": "low", "breakdown": { "input_completeness": 0, "imputation_variance": 0.5, "model_certainty": null } }, "matrix_version": "2026-06-02-first-pass" },
"catalog_version": "0.22.0",
"matrix_version": "2026-06-02-first-pass",
"request_id": "528c7c0a-5a04-4279-a971-13ca7d49633b",
"timestamp": "2026-07-12T23:12:16.334288+00:00"
}
phase: "error" — 200 OK
Returned with a 200 when the engine cannot produce a question — for example an
answers entry references an instrument the engine never issued. The caller
halts; it does not retry blindly.
{
"phase": "error",
"error_code": "unknown_instrument",
"message": "Unknown instrument: 'frobnitz'",
"catalog_version": "0.22.0",
"matrix_version": "2026-06-02-first-pass",
"request_id": "…",
"timestamp": "…"
}
error_code is a stable machine code (unknown_instrument,
instrument_unavailable). Malformed JSON, a missing apikey, an invalid
response_type, or a de-identification violation still produce the standard HTTP
error envelopes (400/401), not a phase: "error" body.
Storing answers (the contract)
The engine is stateless — you store the answers and re-send them each turn,
and you keep them long-term so a scored CDRI can be reproduced and audited. An
answer is always the triple { instrument_id, item_id, value }. The value
type is governed by the question's response_type, so store it accordingly:
response_type | Store value as | Rule | Status |
|---|---|---|---|
number | integer | Within the item's min..max; no decimals. | live |
single_select | integer | The chosen option's value (not its label). | live |
boolean | boolean | true / false. | reserved |
multi_select | array of integers | The chosen option values. | reserved |
free_text | string | Free text (still subject to de-identification). | reserved |
A minimal consumer-side storage shape (adapt to your database):
CREATE TABLE intake_answer (
correlation_token text NOT NULL, -- YOUR opaque subject/session key; the engine never sees it
instrument_id text NOT NULL, -- e.g. "pain_scale"
item_id text NOT NULL, -- e.g. "item_1"
value jsonb NOT NULL, -- typed per response_type above
response_type text NOT NULL, -- captured from the question, for replay validation
instrument_version text NOT NULL, -- provenance: which rubric version was answered
answered_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (correlation_token, instrument_id, item_id) -- last write wins per item
);
Capture response_type and instrument_version from the questions /
batch you rendered. They are not required to re-submit, but they let you
prove later that a stored answer was valid for the instrument version the
subject actually saw, and detect when a rubric has since changed.
Replaying answers. To resume intake or to score, read your rows back into the triple list:
- resume
/intake/next: send them as theanswersarray. - score via
/cdri/compute: reassemble into itsinstrumentsmap —{ instrument_id: { responses: { item_id: value } } }.
Because the engine is deterministic and de-identified, the same stored answers re-submitted later reproduce the same result (for a given catalog + matrix version), which is what makes stored answers audit-grade.
De-identification. Store only these structural values plus your own opaque
correlation_token. Never store names, DOBs, MRNs, or other identifiers against
these rows — the engine rejects them on the way in and they must not live in the
answer store either.
Examples
curl — first call
curl -sS -X POST "https://engine.tripod-health.com/api/intake/next?limit=3" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "answers": [], "context": { "age_bracket": "45-54", "sex": "male", "occupation_class": "construction", "region": "OH" } }'
Python — the intake loop with live risk feedback
# Uses the call() helper from quickstart.md.
answers = []
context = {"age_bracket": "45-54", "sex": "male", "occupation_class": "construction", "region": "OH"}
while True:
turn = call("POST", "/api/intake/next?limit=3", {"answers": answers, "context": context})
render_risk_bars(turn["risk_snapshot"]) # your gamified UI: bars + confidence meter
if turn["phase"] == "complete":
break # or stop early when the clinician arrives
if turn["phase"] == "error":
raise RuntimeError(f"intake halted: {turn['error_code']} - {turn['message']}")
for q in turn["questions"]: # phase == "ask"
value = prompt_user(q) # store {instrument_id,item_id,value} on your side
answers.append({"instrument_id": q["instrument_id"], "item_id": q["item_id"], "value": value})
# On completion, score the stored answers via /api/cdri/compute.
Intended use
- A patient- or clinician-facing intake flow (e.g. a virtual waiting room) that asks the most decision-relevant questions first and shows live risk feedback as the subject answers.
- Collecting the answer set that a later
/cdri/computecall will score, without hard-coding which instruments to administer. - Pacing questions (
?limit) or scoping them (?domain/?phenotype/?response_type) to fit a specific screen or budget.
Mistaken use
- Do not keep state on the engine's behalf and send only new answers — the
endpoint is stateless; always send the full
answerslist. - Do not treat the
risk_snapshotas a scored result — it is the deterministic floor over what's answered so far, with no AI phenotype refinement. The authoritative read is/cdri/compute. - Do not expect the snapshot to move on every answer — it moves when an instrument completes (the scoring cliff).
- Do not treat
phase: "error"as retryable — fix the offending answer and resubmit, or stop. - Do not filter on a reserved
response_typeand expect questions — you get areserved_response_typecompletion, by design.
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Any of phase: "ask", "complete", or "error". |
400 | ValidationError | Body not valid JSON, or an invalid query param (limit, response_type). |
400 | DeIdentificationViolation | Body contained an identifier-shaped value. |
401 | Unauthenticated | Missing or invalid API key. |
413 | PayloadTooLarge | Body exceeds 512 KB. |
429 | RateLimited | Per-consumer rate limit exceeded. |
503 | ServiceUnavailable | A component is unhealthy. |
Related
- composite-cdri-plan.md — the value map: instead of one next batch, it ranks every remaining batch by the confidence it buys. Same VOI core.
- composite-cdri-compute.md — score the collected answers when intake completes.
- patterns-questions.md — the item shape returned in
questions. - conventions.md — de-identification and standing rules.
Change history
| Date | Change |
|---|---|
| 2026-06-03 | Initial publication. |
| 2026-07-12 | Rebuilt on the value-of-information core: VOI-ordered item drip (?limit), ?domain/?phenotype/?response_type filters, phase: "complete" reasons, a per-turn deterministic risk_snapshot, and the answer-storage contract. Response reshaped (batch + risk_snapshot); no per-turn corpus write. |