Suggest Nudges
Status:
stableCategory:trinityassistAdded:2026-05-26Last reviewed:2026-10-11
Summary
Inspect a window of finalized transcript segments and return zero or
more clinical-decision nudge candidates for the reader you describe. Each
candidate carries a category from a closed vocabulary, a short imperative
body, a severity, and the segment ids the model cited as the trigger.
Empty arrays are common and correct -- most short windows do not warrant a
nudge.
Suggest is the middle member of a three-call family. It works from the
latest brief from the nudge helper and the bodies of the
documents that brief named, checks for itself whether the conversation has
moved on since the brief was written, and says so in brief_check. Pair it
with check_resolution to retire satisfied
cards.
Nudge bodies are AI-generated within engine guardrails. There is no fixed prompt registry. Categories, by contrast, ARE fixed: see Nudge categories.
:::caution The engine is stateless -- send active_nudges
The engine holds nothing between calls. If you poll this endpoint on a
cadence over an overlapping transcript window and do not send
active_nudges, you will be handed the same gap again on every cycle
with a new card_id each time, and your rail will fill with duplicates.
active_nudges is the dedup key. Send every card currently on the
clinician's screen on every call.
:::
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/nudge/suggest |
| Full URL | https://engine.tripod-health.com/api/nudge/suggest |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (each call may produce a fresh candidate set) |
| Rate-limited | Yes |
Generated text passes the engine's outbound de-identification scan
before returning. A candidate whose body fails that scan is dropped
from the response rather than scrubbed or returned. See
De-identification contract.
Purpose
The consumer drives this endpoint over the course of a clinical encounter -- typically on a recurring window during a live visit, but equally for a one-shot pass over a recorded transcript.
A working live-visit loop:
- Buffer finalized transcript segments as they arrive.
- Every ~90 seconds, call the nudge helper and keep
its
briefas the current brief. - Every ~60 seconds, POST the most recent window here with every open
card, your
audience, the current brief, and the bodies of the documents inbrief.directive_refsasassist_contextreferences loadedalways. Add the returned candidates to the rail. - Every ~30 seconds, offset from suggest, POST the window and the open
cards to
check_resolutionand retire the cards it marks resolved. - Retire a card immediately when the clinician dismisses it, without waiting for a sweep.
If the helper has not run or failed, call suggest without a brief. It
works from the transcript and intake alone and reports brief_check: "no_brief". All three endpoints are billed per call, so cost scales with
visit length and cadence; see credits.md.
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-ankle-01",
"audience": {
"label": "Athletic trainer",
"scope": "May give first aid, coach self-examination and set expectations. May not order imaging, prescribe or set work restrictions; routes those to the physician of record.",
"categories": [
"mechanism-of-injury", "pain-characterization", "onset-timeline", "job-demands",
"virtual-exam-opportunity", "clinical-decision-rule", "scope-boundary", "follow-up-plan"
]
},
"intake": {
"description": "I stepped off the edge of the loading dock wrong and my right ankle rolled underneath me. It swelled up fast.",
"body_region": "Lower leg / ankle"
},
"visit_context": {
"visit_type": "intake",
"encounter_purpose": "comprehensive",
"days_since_injury": 2,
"prior_encounter_count": 0
},
"brief": {
"focus": {
"summary": "Acute right ankle inversion injury two days ago, with fast swelling and painful weight bearing.",
"body_region": "right ankle",
"complaint_kind": "acute sprain"
},
"intake_alignment": { "status": "consistent", "note": "The conversation matches the patient's description." },
"shift": { "shifted": false, "note": null },
"directive_refs": ["care:musculoskeletal/ankle"],
"notes": ["Weight bearing immediately after the injury and now has not been established."]
},
"active_nudges": [
{
"card_id": "01KSJTM039H6H6Z5PJQSDD2V30",
"category": "mechanism-of-injury",
"body": "Ask what position the ankle was in when it rolled, and whether the patient felt a pop."
}
],
"assist_context": {
"precedence": ["global.boundaries", "practice.care_directives"],
"instructions": [
{ "layer": "global.boundaries",
"text": "Visits are by video and are workers' compensation visits. Clinicians never examine hands-on." }
],
"references": [
{ "ref": "care:musculoskeletal/ankle", "layer": "practice.care_directives", "title": "Ankle",
"load": "always",
"body": "Assess weight bearing immediately after the injury and at the visit. Prompt the Ottawa Ankle Rules before any imaging discussion. Coach self-palpation of the posterior edge and tip of both malleoli. Escalate when the patient cannot bear weight for four steps." }
]
},
"transcript_window": [
{ "segment_id": "s1", "speaker": "provider", "text": "Tell me what happened.",
"started_at_ms": 0, "ended_at_ms": 2400 },
{ "segment_id": "s2", "speaker": "patient",
"text": "I stepped off the edge of the loading dock wrong and my right ankle rolled underneath me.",
"started_at_ms": 2400, "ended_at_ms": 9100 },
{ "segment_id": "s3", "speaker": "patient",
"text": "It swelled up pretty fast. I can hobble on it but it hurts a lot to put weight on that side.",
"started_at_ms": 9100, "ended_at_ms": 15600 }
]
}
Describing your reader
audience is required and has no default: the prompt is written for this
reader, and a default would silently write advice in one reader's scope for
another. Describe them yourself:
label-- what they are called.scope-- what they may act on and who they route to. It is placed in the prompt verbatim as the boundary of what a nudge may ask of them.categories-- the categories they may receive. The safety categories are added for every audience whether listed or not, and a candidate outside the rest is dropped.
The engine assumes nothing else about your setting. If your visits are
remote, or are workers' compensation visits, say so in your scope or your
assist_context.instructions.
The brief and your directives
When you send a brief, the model reads it, checks it against the
transcript, and reports brief_check: consistent when the conversation
still matches the brief's focus, shifted when it has moved on (it then
works from the transcript and ignores directives about the old focus). Your
instructions and loaded references are followed where they apply to what is
being discussed.
Send the documents the brief named as references with load: "always" and
their full body -- at most four travel, so a call stays well inside the
payload limit. On-demand references are not accepted: the engine runs no
read loop, and the helper has already chosen. The documents stay
byte-identical between calls while the brief names the same refs, so they
sit inside the cached part of the prompt.
Body fields
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
encounter_id | string | No | 1-64 chars | Optional opaque correlation key. Send the same value on helper and check_resolution to keep the calls grouped in your own logs. |
audience | object | Yes | -- | Who reads the rail. See Describing your reader. |
audience.label | string | Yes | 1-80 chars | What the reader is called. |
audience.scope | string | Yes | 1-1,500 chars | What they may act on and who they route to. Rendered verbatim. |
audience.categories | array | Yes | 1-64 entries, each a value from Nudge categories | Categories they may receive. An unknown value is a 400. Safety categories are always eligible. |
transcript_window | array | Yes | 1-256 segments | Finalized transcript segments to inspect. |
transcript_window[].segment_id | string | Yes | 1-256 chars | Consumer-supplied id. Treated as opaque by the engine; a <session_id>:<seq_no> shape makes a returned triggered_by_segment_ids traceable. |
transcript_window[].speaker | string | Yes | 1-32 chars | Any speaker label your transcription source emits. |
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. |
active_nudges | array | No | 0-64 items, default [] | The cards currently on the clinician's screen. Effectively required for cadence callers -- see the caution above. |
active_nudges[].card_id | string | Yes* | 1-32 chars | The card_id the engine issued. *Required when the enclosing object is present. |
active_nudges[].category | string | Yes* | ^[a-z0-9-]+$, max 64 chars | The card's category. |
active_nudges[].body | string | Yes* | 1-280 chars | The body the clinician saw. |
intake | object | No | same shape as on helper | description (1-4,000), body_region (1-120), answers (0-40 pairs of question 1-200 / answer 1-1,000). |
visit_context | object | No | -- | Structural facts about the visit. Every field optional. |
visit_context.visit_type | enum | No | intake | follow_up | Whether this is the first visit for the complaint. |
visit_context.encounter_purpose | enum | No | comprehensive | standard | Depth of the encounter. |
visit_context.days_since_injury | integer | No | 0-25,000 | Days ELAPSED since the injury. A count, never a date. |
visit_context.prior_encounter_count | integer | No | 0-1,000 | Prior encounters on this case, excluding the current one. |
brief | object | No | a brief | The helper's latest brief, unchanged. At most 4 directive_refs and 3 notes. |
assist_context | object | No | see assist_context | Your persona, instructions, and references loaded always. On-demand references are rejected. |
Outside the scrubbed free text (see below) the body must not contain identifier-shaped values -- names, dates of birth, SSN-shaped values, phone numbers, email addresses, MRNs.
Nudge categories
category is a closed vocabulary. Every returned card carries exactly
one of these values, which is what makes deduplication and
cross-encounter analytics tractable.
| Group | Categories |
|---|---|
| Safety and escalation | emergency-activation, escalation-indicated, red-flag-screening, patient-location-unconfirmed |
| Subjective gaps | mechanism-of-injury, work-relatedness, pain-characterization, onset-timeline, prior-history, functional-limitation, job-demands, secondary-complaint, psychosocial-context |
| Objective gaps | virtual-exam-opportunity, bilateral-comparison, neurovascular-screen, clinical-decision-rule, exam-source-unclear |
| Assessment and plan | differential-consideration, otc-contraindication-check, scope-boundary, patient-education-gap, follow-up-plan, return-to-work-expectation |
| Charting | documentation-gap |
| Non-clinical | care-coordination, outside-care-history, outside-imaging-or-prescription, appointment-logistics, patient-rapport, language-or-access-barrier, transportation-or-attendance-barrier, consent-or-authorization-gap |
The safety categories are the safety and escalation group plus
otc-contraindication-check. They are eligible for every audience, and
check_resolution never retires one -- only
the clinician clears a safety card.
This vocabulary can gain entries in a future release. Treat an
unrecognized category as renderable-but-uncategorized rather than an
error.
Response
Success -- 200 OK
{
"candidates": [
{
"card_id": "01KSJTM03G3BQ299M77N8TQ017",
"category": "clinical-decision-rule",
"body": "Consider the Ottawa Ankle Rules -- ask whether the patient could bear weight immediately after and now, and coach self-palpation of both malleoli.",
"severity": "suggestion",
"triggered_by_segment_ids": ["s2", "s3"]
},
{
"card_id": "01KSJTM03P4XZ9K2W8RB5NC0AF",
"category": "pain-characterization",
"body": "Ask the patient to rate the ankle pain 0-10 now, at its best, and at its worst.",
"severity": "suggestion",
"triggered_by_segment_ids": ["s3"]
}
],
"brief_check": "consistent",
"request_id": "0926afd2-e6a7-4303-841b-667775135b87",
"timestamp": "2026-10-11T18:20:47.565951+00:00"
}
mechanism-of-injury was already open in active_nudges, so it was not
proposed again even though s2 is nothing but mechanism detail. The
Ottawa card follows the loaded ankle directive.
Generation is not deterministic. The same window will not always produce the same cards. Treat the categories as a sample of what the window supports, and never assert on an exact card set in your own tests.
Three candidates is the per-call ceiling, and at most two open cards may
share a category. An empty candidates array is common and is not an
error; it always comes with a silence_reason.
Response fields
| Field | Type | Always present | Description |
|---|---|---|---|
candidates | array | Yes | Zero or more candidate nudges. |
candidates[].card_id | string | Yes | Engine-issued 26-char ULID, unique per candidate. Echo it back on check_resolution. |
candidates[].category | enum | Yes | One of Nudge categories, and always one your audience may receive. |
candidates[].body | string | Yes | Short imperative copy, max 280 chars. Never markup. |
candidates[].severity | enum | Yes | info | suggestion | reminder. |
candidates[].triggered_by_segment_ids | array | Yes | The request segment_id values the model cited. May be empty (rare). |
silence_reason | enum | Only when candidates is empty | nothing_to_flag (substantive conversation, no gap), all_covered_by_open_cards, or conversation_too_thin. |
silence_note | string | No | One diagnostic sentence, only alongside silence_reason. Never advice. |
brief_check | enum | Yes | consistent | shifted when you sent a brief; no_brief when you did not. A brief the model did not judge is reported as shifted, never as consistent. |
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 | Candidates generated (zero or more). |
400 | ValidationError | Body shape failure -- a missing audience, an audience.categories value outside the vocabulary, an on-demand reference, an unknown field, a malformed brief. field names the offender. |
400 | DeIdentificationViolation | An identifier-shaped value outside the scrubbed fields. |
401 | Unauthenticated | Missing or invalid API key. |
402 | PaymentRequired | Out of credits, or a key or account budget is exhausted. |
413 | PayloadTooLarge | Body exceeds 512 KB. |
429 | RateLimited | Per-consumer rate limit exceeded. |
502 | UpstreamError | Upstream model call failed after one retry. A candidate failing the outbound de-id pass does NOT produce a 502 -- it is dropped. |
503 | ServiceUnavailable | Service temporarily unhealthy. |
See errors.md for the full error code list and envelope.
Timeouts and cost
One balanced-tier pass, held to 20 seconds per attempt with one retry, so at most about 40 seconds. A client timeout of about 65 seconds is right. The stable part of the prompt (rules, persona, instructions, the brief's documents) is cached between suggestions in the same visit, so consecutive calls are cheaper to serve than the first. See credits.md.
Examples
curl
curl -sS -X POST "https://engine.tripod-health.com/api/nudge/suggest" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"audience": {
"label": "Physician",
"scope": "Treating physician of record. May order imaging, prescribe and set work restrictions.",
"categories": ["pain-characterization", "clinical-decision-rule", "return-to-work-expectation"]
},
"transcript_window": [
{"segment_id": "s1", "speaker": "provider", "text": "Tell me what happened.", "started_at_ms": 0, "ended_at_ms": 2400},
{"segment_id": "s2", "speaker": "patient", "text": "My right ankle rolled underneath me on the dock.", "started_at_ms": 2400, "ended_at_ms": 9100}
]
}'
Python (requests)
import os, requests
resp = requests.post(
"https://engine.tripod-health.com/api/nudge/suggest",
headers={"apikey": os.environ["ENGINE_API_KEY"]},
json={
"audience": {
"label": "Physician",
"scope": "Treating physician of record. May order imaging, prescribe and set work restrictions.",
"categories": ["pain-characterization", "clinical-decision-rule"],
},
"brief": current_brief, # from /api/nudge/helper, or omit
"active_nudges": open_cards,
"transcript_window": window,
},
timeout=65,
)
resp.raise_for_status()
body = resp.json()
for c in body["candidates"]:
print(f"[{c['severity']}] {c['card_id']} {c['category']}: {c['body']}")
print("brief_check:", body["brief_check"])
Intended use
- Driving a live in-call advisory rail, with the helper's brief deciding which of your directives travel.
- One-shot passes over a recorded transcript window, with or without a brief.
- Evaluation harnesses comparing prompts, audiences or directive sets.
Mistaken use
- Calling on a cadence without
active_nudges. The engine cannot know what it already told you; every cycle re-proposes the same gaps under freshcard_ids. - Sending your whole directive library. Send the documents the brief
named, loaded
always. The helper exists so you do not have to send the rest. - Putting a reader's permissions anywhere but
audience.scope. The scope is what bounds every card. Rules about the setting (remote visits, programme rules) belong inassist_context.instructions. - Treating a safety card as something the engine will retire on its own.
It will not -- see
check_resolution. - Sending an incident DATE in
visit_context.days_since_injury. The field is an elapsed day count; a date is an identifier. - Persisting nudge bodies as clinical documentation. They are prompts to a clinician, not findings.
Debugging
- Capture the
request_idfrom the response body (or, if the response has no body from the engine, theX-Request-IDresponse header). - Empty
candidates-- readsilence_reason.conversation_too_thinearly in a visit is normal. 400 ValidationErroronaudience.categories[N]-- the value is not an engine category. Check it against Nudge categories.400 ValidationErroronassist_context.references[N].load-- on-demand references are not accepted here; send the documentalwayswith its body.brief_checkisshiftedevery time -- the brief is older than the conversation. Refresh it from the helper more often, or check that you are sending the latest one.502 UpstreamError-- retry on the next cadence tick rather than immediately.- The same nudge keeps reappearing under new
card_ids -- you are not sendingactive_nudges, or you are dropping dismissed cards from it while they are still on screen.
De-identification contract
- Inbound, free text: identifier spans inside
transcript_window[].text,intake.descriptionandintake.answers[].answerare SCRUBBED, not rejected. Everything downstream, including anything the engine persists, sees only the scrubbed text. - Inbound, everything else: the rest of the body is walked for
identifier-shaped strings and a match returns
400 DeIdentificationViolation. Your instructions and references are scrubbed of identifier spans too; seeassist_context. - Outbound: every generated
bodyis scanned for identifier shapes. A matching candidate is DROPPED and logged; it does not fail the call.
See conventions.md.
Rate limits
Per-consumer limits are enforced at the gateway. See rate-limits.md.
Related
POST /api/nudge/helper-- produces the brief.POST /api/nudge/check_resolution-- decides which open nudges the conversation has resolved.- conventions.md -- de-identification contract, request IDs, timestamp shape.
- errors.md -- error envelope and code list.
Change history
| Date | Change |
|---|---|
| 2026-10-11 | Breaking. audience is now an object you describe (label, scope, categories) instead of a fixed token; candidates outside its categories (safety excepted) are dropped. New optional intake and brief, and the response always carries brief_check. visit_context keeps only visit_type, encounter_purpose, days_since_injury and prior_encounter_count. One pass: on-demand references and the engine read loop are gone, and references must be loaded always. The engine no longer assumes a workers' compensation or telehealth setting. |
| 2026-10-11 | Accepts assist_context, including on-demand references the engine reads for the model (at most two per call). Generation moved to the balanced capability tier; allow up to 45 seconds per call. |
| 2026-08-09 | Replaced the worked request and response with a real production exchange. Documented that generation is not deterministic and that an empty candidates array is normal for a short window. Suggested cadence in the live-visit loop corrected from ~3 minutes to ~90 seconds. No wire change. |
| 2026-08-08 | Added active_nudges and visit_context to the request. category became a closed vocabulary. The engine now refuses to re-propose a category present in active_nudges. Corrected the documented outbound de-id behaviour: a failing candidate is dropped, not a 502. |
| 2026-05-26 | Initial publication. Replaces the retired GET /api/nudge/prompt registry lookup. Nudge bodies are now AI-generated within engine guardrails instead of resolved from a fixed prompt catalog. |
| 2026-05-26 | Resource version bumped to 0.3.0. Response now carries an engine-issued card_id (26-char ULID) on every candidate; echo it back as the card_id on POST /api/nudge/check_resolution to evaluate resolution later. Request body gains an optional encounter_id (max 64 chars) to correlate suggest -> check_resolution calls in your own logs. Both additions are 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. |