Check Nudges
Status:
stableCategory:trinityassistAdded:2026-05-26Last reviewed:2026-10-11
Summary
Given a list of currently-open clinical-decision nudges and a window
of finalized transcript segments, decide which nudges have been
satisfied by the conversation. Returns one verdict per supplied
card -- resolved: true plus a one-line reason when the
conversation addressed the nudge, resolved: false when it did not.
This is a stateless HTTP endpoint: the consumer supplies its currently open nudges plus a transcript window and gets back a per-nudge verdict. The consumer owns cadence -- a live-visit integration typically calls this on a recurring window to clear nudges the conversation has since addressed.
Each card is judged against its own text, which already carries what any
directive asked for, so this call takes no directives and no
assist_context. That keeps it small and fast enough to run every 30
seconds. It is the third member of the nudge family, with the
nudge helper and suggest.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/nudge/check_resolution |
| Full URL | https://engine.tripod-health.com/api/nudge/check_resolution |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (each call asks the model afresh) |
| Rate-limited | Yes |
Purpose
A nudge card has three ways to leave the rail:
engine_resolved-- this endpoint. A recurring sweep over the transcript window decides an open nudge has been answered by the conversation.user_resolved-- the clinician clears the card themselves.system_resolved-- consumer-side logic (a co-pilot, an app heuristic) retires the card.
Only the first is the engine's business; the other two are entirely
yours. A typical live-visit integration calls this every ~30 seconds,
offset from suggest so the two never share a tick,
with every open card and the same window it feeds suggest.
When a nudge counts as resolved
The engine marks a card resolved when:
- the clinician asked the question the nudge prompted, OR
- the patient volunteered the information the nudge was trying to elicit, OR
- the clinician did the thing the nudge suggested -- coached the self-examination, named the decision rule, said they were escalating.
For non-safety categories only, a card is also resolved when the conversation has clearly moved past the clinical context it applied to and acting on it later would no longer be useful.
Partial does not count. If a card asked for a 0-10 pain score and the
patient said "it hurts a lot", the verdict is false.
:::warning Safety cards are never auto-resolved
Cards in these categories are only ever cleared by the clinician:
emergency-activation, escalation-indicated, red-flag-screening,
patient-location-unconfirmed, otc-contraindication-check
This is absolute, and stronger than "the conversation moved on does
not count." A safety card sent in active_nudges always comes back
resolved: false, even when the window plainly answers it. Ask about
neurovascular symptoms, get a clean denial, and the card still returns
false.
That is deliberate. Asking once and getting a "no" is not the same as the concern being closed -- the patient in front of the clinician can deteriorate over the next twenty minutes, and the card is the reminder to look again. The asymmetry decides it: wrongly keeping a red-flag card costs a line of screen space, wrongly retiring one silently removes the only prompt to re-screen.
The rule is enforced in code, not requested of the model, so it holds regardless of what the underlying model returns. Do not build a client-side timeout that clears these either.
:::
Authentication
Standard API-key. Set apikey: $ENGINE_API_KEY on the request. See
authentication.md.
Request
Headers
| Header | Required | Notes |
|---|---|---|
apikey | Yes | Your engine API key. |
Content-Type | Yes | application/json. |
Body
{
"encounter_id": "visit-2026-05-26-shoulder-01",
"active_nudges": [
{
"card_id": "01KSJTM039H6H6Z5PJQSDD2V30",
"category": "pain-characterization",
"body": "Ask about pain severity on a 0-10 scale."
},
{
"card_id": "01KSJTM03G3BQ299M77N8TQ017",
"category": "functional-limitation",
"body": "Ask how the back pain is affecting daily activities, work, or sleep."
}
],
"transcript_window": [
{
"segment_id": "s10",
"speaker": "provider",
"text": "On a scale of zero to ten what is your pain right now?",
"started_at_ms": 0,
"ended_at_ms": 3000
},
{
"segment_id": "s11",
"speaker": "patient",
"text": "Right now sitting here it is about a five.",
"started_at_ms": 3000,
"ended_at_ms": 6000
},
{
"segment_id": "s12",
"speaker": "patient",
"text": "When I drive in this morning it was more like an eight.",
"started_at_ms": 6000,
"ended_at_ms": 10000
},
{
"segment_id": "s13",
"speaker": "provider",
"text": "Are you still able to do your normal activities, working out, sleeping?",
"started_at_ms": 10000,
"ended_at_ms": 14000
},
{
"segment_id": "s14",
"speaker": "patient",
"text": "I have been okay at work, just lighter duty since lifting is what flares it up.",
"started_at_ms": 14000,
"ended_at_ms": 19000
}
]
}
Body fields
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
encounter_id | string | No | 1-64 chars | Optional opaque correlation key. When you sent an encounter_id on the matching POST /api/nudge/suggest call, send the same value here so both calls stay grouped under one visit in your own logs. Treated as opaque by the engine. |
active_nudges | array | Yes | 1-64 entries | Currently-open nudges to evaluate. The endpoint returns one verdict per entry; the order is preserved. |
active_nudges[].card_id | string | Yes | 1-32 chars | The card_id you got back on POST /api/nudge/suggest (26-char ULID), or any non-empty consumer-supplied id when calling this endpoint standalone. |
active_nudges[].category | string | Yes | 1-64 chars, ^[a-z0-9-]+$ | Kebab-case slug for the nudge category. |
active_nudges[].body | string | Yes | 1-280 chars | The imperative copy the clinician saw. The model uses this to decide what would count as resolution. |
transcript_window | array | Yes | 1-256 segments | Finalized transcript segments to evaluate against. Same shape as the transcript_window on POST /api/nudge/suggest. |
transcript_window[].segment_id | string | Yes | 1-256 chars | Consumer-supplied id. |
transcript_window[].speaker | string | Yes | 1-32 chars | Speaker label. |
transcript_window[].text | string | Yes | 1-8,000 chars | Utterance text. |
transcript_window[].started_at_ms | integer | Yes | >=0 | Offset (ms) from session start. |
transcript_window[].ended_at_ms | integer | Yes | >=0 | Offset (ms) from session start. |
The body MUST NOT contain identifier-shaped values. See conventions.md.
Response
Success -- 200 OK
{
"resolutions": [
{
"card_id": "01KSJTM039H6H6Z5PJQSDD2V30",
"resolved": true,
"reason": "Patient reported pain severity: 5/10 while sitting, 8/10 during driving."
},
{
"card_id": "01KSJTM03G3BQ299M77N8TQ017",
"resolved": true,
"reason": "Patient described functional impact: modified work duties with lighter duty due to pain with lifting."
}
],
"request_id": "48700f18-afac-471a-a7da-0799bddfe408",
"timestamp": "2026-05-26T18:20:01.817595+00:00"
}
Two open cards, both closed by a window where the clinician asked and
the patient answered. Note that the verdicts come back keyed by the
same card_id values the request sent -- matching them up is your
job, and the engine will never invent an id you did not supply.
Response fields
| Field | Type | Always present | Description |
|---|---|---|---|
resolutions | array | Yes | One entry per active_nudges input, in the same order. |
resolutions[].card_id | string | Yes | Echo of the input card_id. |
resolutions[].resolved | boolean | Yes | true when the model decided the transcript window addressed the nudge; false otherwise. |
resolutions[].reason | string | null | Yes | One-line natural-language explanation. Non-null when resolved is true; typically null when resolved is false. |
request_id | string | Yes | Engine-assigned request ID. |
timestamp | string | Yes | ISO 8601 UTC server timestamp at response emission. |
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Verdicts generated for every supplied card. |
400 | ValidationError | Body shape failure -- empty active_nudges, empty transcript_window, malformed entry, category not kebab-case, body too long, or assist_context sent (not accepted here). field names the offender. |
400 | DeIdentificationViolation | Body carries an identifier-shaped value. |
401 | Unauthenticated | Missing or invalid API key. |
413 | PayloadTooLarge | Body exceeds 512 KB. |
415 | UnsupportedMediaType | Content-Type is not application/json. |
429 | RateLimited | Per-consumer rate limit exceeded. |
500 | InternalError | Unexpected failure. |
502 | UpstreamError | Upstream model call failed. |
503 | ServiceUnavailable | Service temporarily unhealthy. |
Error envelope
{
"timestamp": "2026-05-26T18:20:18.187Z",
"error": "ValidationError",
"message": "active_nudges[0].category must match /^[a-z0-9-]+$/",
"field": "active_nudges[0].category",
"request_id": "3ac6869f-154d-47df-864f-294ce1632e6f"
}
See errors.md for the full error code list.
Examples
curl
curl -sS -X POST "https://engine.tripod-health.com/api/nudge/check_resolution" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"encounter_id": "visit-2026-05-26-shoulder-01",
"active_nudges": [
{"card_id": "01KSJTM039H6H6Z5PJQSDD2V30", "category": "pain-characterization", "body": "Ask about pain severity on a 0-10 scale."},
{"card_id": "01KSJTM03G3BQ299M77N8TQ017", "category": "functional-limitation", "body": "Ask how the back pain is affecting daily activities, work, or sleep."}
],
"transcript_window": [
{"segment_id": "s10", "speaker": "provider", "text": "On a scale of zero to ten what is your pain right now?", "started_at_ms": 0, "ended_at_ms": 3000},
{"segment_id": "s11", "speaker": "patient", "text": "Right now sitting here it is about a five.", "started_at_ms": 3000, "ended_at_ms": 6000},
{"segment_id": "s12", "speaker": "patient", "text": "When I drive in this morning it was more like an eight.", "started_at_ms": 6000, "ended_at_ms": 10000},
{"segment_id": "s13", "speaker": "provider", "text": "Are you still able to do your normal activities, working out, sleeping?", "started_at_ms": 10000, "ended_at_ms": 14000},
{"segment_id": "s14", "speaker": "patient", "text": "I have been okay at work, just lighter duty since lifting is what flares it up.", "started_at_ms": 14000, "ended_at_ms": 19000}
]
}'
encounter_id is optional. Send the same value you sent on the
matching suggest call to keep both calls grouped under one visit.
Omit it for one-shot resolution checks.
Python (requests)
import os, requests
resp = requests.post(
"https://engine.tripod-health.com/api/nudge/check_resolution",
headers={"apikey": os.environ["ENGINE_API_KEY"]},
json={
"encounter_id": "visit-2026-05-26-shoulder-01",
"active_nudges": [
{"card_id": "01KSJTM039H6H6Z5PJQSDD2V30", "category": "pain-characterization",
"body": "Ask about pain severity on a 0-10 scale."},
{"card_id": "01KSJTM03G3BQ299M77N8TQ017", "category": "functional-limitation",
"body": "Ask how the back pain is affecting daily activities, work, or sleep."},
],
"transcript_window": [
{"segment_id": "s10", "speaker": "provider",
"text": "On a scale of zero to ten what is your pain right now?",
"started_at_ms": 0, "ended_at_ms": 3000},
{"segment_id": "s11", "speaker": "patient",
"text": "Right now sitting here it is about a five.",
"started_at_ms": 3000, "ended_at_ms": 6000},
],
},
timeout=30,
)
resp.raise_for_status()
for r in resp.json()["resolutions"]:
state = "RESOLVED" if r["resolved"] else "open"
print(f"{r['card_id']} -> {state}: {r['reason']}")
Node.js (fetch)
const resp = await fetch(
"https://engine.tripod-health.com/api/nudge/check_resolution",
{
method: "POST",
headers: {
apikey: process.env.ENGINE_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
encounter_id: "visit-2026-05-26-shoulder-01",
active_nudges: [
{ card_id: "01KSJTM039H6H6Z5PJQSDD2V30", category: "pain-characterization",
body: "Ask about pain severity on a 0-10 scale." },
{ card_id: "01KSJTM03G3BQ299M77N8TQ017", category: "functional-limitation",
body: "Ask how the back pain is affecting daily activities, work, or sleep." },
],
transcript_window: [
{ segment_id: "s10", speaker: "provider",
text: "On a scale of zero to ten what is your pain right now?",
started_at_ms: 0, ended_at_ms: 3000 },
{ segment_id: "s11", speaker: "patient",
text: "Right now sitting here it is about a five.",
started_at_ms: 3000, ended_at_ms: 6000 },
],
}),
},
);
if (!resp.ok) throw new Error(`Engine ${resp.status}: ${await resp.text()}`);
for (const r of (await resp.json()).resolutions) {
console.log(`${r.card_id} -> ${r.resolved ? "RESOLVED" : "open"}: ${r.reason}`);
}
Intended use
- The recurring resolution sweep of a live in-call rail: every ~30
seconds, send every open card plus the current window and retire
whatever comes back
resolved: true. - One-shot sweeps over a recorded transcript window.
- Eval harnesses driving the same resolution primitive a live integration uses.
Mistaken use
- Auto-clearing a safety card on a timer. The engine will never
return
resolved: truefor one on the "conversation moved on" rule; a client-side timeout that clears it anyway defeats the entire point of the category. - Sending only some open cards. The endpoint returns one verdict per card you send and knows nothing about the ones you withheld; a card omitted from the sweep simply never gets a chance to close.
- Calling this without also calling it again. A single sweep is nearly useless -- resolution is a cadence, not an event.
- Treating an absent
reasonon aresolved: falserow as an error. The model omits a reason when nothing in the window addressed the nudge -- that is the expected shape. - Re-sending a card you already retired locally. Harmless (the engine is stateless and will just re-evaluate it) but it costs a metered call's worth of prompt for a card nobody can see.
- Sending raw patient identifiers outside
transcript_window[].text.
Debugging
- Capture the
request_idfrom the response body, success or error (or, if the response has no body from the engine, theX-Request-IDresponse header). 400 ValidationErroronactive_nudges[N].category-- the regex^[a-z0-9-]+$enforces kebab-case. Reject anything with uppercase, underscores, or spaces on your side before sending.400 ValidationErrorontranscript_window-- empty arrays are rejected. The minimum is one segment; the maximum is 256.502 UpstreamError-- the model call failed. The reasons are the same as onPOST /api/nudge/suggest.- All verdicts come back
falseand you expectedtrue-- the model is conservative about claiming resolution. Make sure thebodyyou pass on eachactive_nudgesentry is the same body the model originally generated (or a faithful representation of it); paraphrasing the body changes what the model treats as resolution criteria.
De-identification contract
- Inbound: the request body is walked for identifier-shaped
strings; failures return
400 DeIdentificationViolation. - Outbound: every generated
reasonis scanned for identifier shapes. A verdict whose reason fails the scan comes backresolved: falsewith anullreason; it does not fail the call, and the card stays open until a later sweep clears it.
See conventions.md.
Rate limits
Per-consumer limits are enforced at the gateway. See rate-limits.md.
Related
POST /api/nudge/suggest-- companion endpoint that proposes new nudges for a transcript window.POST /api/nudge/helper-- works out what the visit is about for suggest.- conventions.md -- de-identification contract, request IDs, timestamp shape.
- errors.md -- error envelope and code list.
Change history
| Date | Change |
|---|---|
| 2026-10-11 | Breaking. assist_context is no longer accepted: each card is judged against its own text. Sending it returns 400. Corrected the documented outbound de-id behaviour: a failing reason downgrades that verdict to unresolved rather than failing the call. |
| 2026-10-11 | Accepts assist_context (instructions and always-loaded references; no on-demand references). |
| 2026-08-09 | Corrected the safety-category rule, which was understated. A safety card always returns resolved: false, including when the window plainly answers it -- not merely when the conversation has moved on. The rule is now enforced in engine code rather than requested of the model, so it holds regardless of model output. Behaviour change for any consumer that was seeing safety cards resolve. |
| 2026-08-08 | Documented the safety-category rule: cards in the safety and escalation categories are never resolved by the conversation moving on, only by the clinician. No wire change. |
| 2026-05-26 | Initial publication. Companion to the engine-owned nudge lifecycle that replaced the retired prompt registry. |
| 2026-05-26 | Resource version bumped to 0.3.0. Request body gains an optional encounter_id (max 64 chars) so callers chaining suggest -> check_resolution can keep both calls grouped under the same correlation key in their own logs. Non-breaking. |
| 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. |