Loading documentation

Access required

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

Sign out
Skip to main content

Assist Narrative

Status: beta Category: trinityassist Added: 2026-08-16 Last reviewed: 2026-10-11

Summary​

Turn a de-identified fact bundle into the sections of a written summary. You send the facts and a section spec — a list saying what each section is called, whether it is prose or bullets, and how much content it takes — and the engine returns those sections written, in that order, ready to display.

One endpoint covers every kind of summary you need. A per-person read, a per-case read, a per-visit read, a risk read, and a daily brief are not five different generators: they differ only in which sections you ask for and which facts you supply, and you own both. Adding a sixth kind of summary is a change on your side with no engine change at all.

Cost is variable — it scales with how much the model reads and writes (see What a call costs).

Endpoint​

FieldValue
MethodPOST
Path/api/assist/summarize
Full URLhttps://engine.tripod-health.com/api/assist/summarize
AuthenticationAPI key (required)
Request bodyapplication/json
Response bodyapplication/json
IdempotentNo (generated text varies between calls)
Rate-limitedYes

Stateless. Nothing is stored and nothing is remembered between calls: every call carries everything it needs.

Purpose​

Use this endpoint when you have structured facts and need them written up as prose a person will read.

The division of responsibility is the point of the design, and it is worth being explicit about:

  • You own the spec. Section labels, their order, prose vs. bullets, and how many sentences or bullets each takes. These are editorial decisions about your own product surface, they live in your code next to whatever renders them, and changing one is a deploy on your side only.
  • The engine owns the writing. A default voice (yours, if you send one in assist_context), the rule that every claim must be traceable to the facts you supplied, how numbers and dates are rendered, and what to do when the facts do not support a section.

The consequence you should rely on: the engine will not invent a fact to fill a section. It summarises, aggregates, compares, and draws the consequence of what you sent. If your bundle does not support a section, the returned text says what is not yet known rather than producing a plausible specific. That is deliberate — a summary that reads authoritative and is partly invented is worse than a short one.

Authentication​

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

Request​

Headers​

HeaderRequiredDescription
apikeyYesConsumer API key.
Content-TypeYesMust be application/json.

Path parameters​

None.

Query parameters​

None. The model is selected by the engine — which model writes a summary is a product decision, not a caller parameter. It is reported back on the response as model, because it determines the price.

Body​

{
"subject_type": "case",
"subject_id": "c1f0a6d2-4b77-4a1e-9f3e-2d5b8c9a1e44",
"section_spec": [
{ "label": "The story so far", "shape": "prose", "sentences": 2 },
{
"label": "Key events",
"shape": "bullets",
"bullets": 3,
"slots": [
"The injury, and how long it took to report",
"The most significant change since then",
"Where recovery stands now"
]
},
{ "label": "Watch for", "shape": "prose", "sentences": [2, 3] }
],
"subject_facts": {
"days_since_injury": 19,
"visits": 3,
"reported_after_days": 9,
"work_status": "modified duty, 20 lb lifting limit",
"pain_trend": "improving, rated 6 of 10 at the last visit",
"outstanding": ["imaging ordered, not yet returned"]
}
}

Body fields​

FieldTypeRequiredConstraintsDescription
subject_typestringYesone of patient_chart, case, encounter, case_risk, clinician_day, customWhat the summary is about. Sets the altitude of the writing — see Subject types.
task_briefstringWith custom only1–8,000 charsWhat a custom summary is for, in your words. Required with custom; rejected with any other subject type.
subject_idstringNonon-empty, ≤ 64 charsOpaque correlation key, echoed on the response. Never resolved, never stored, never shown to the model.
clinician_user_id_hashstringNonon-empty, ≤ 128 charsOpaque correlation key. Same treatment as subject_id.
section_specarrayYes1–12 entries, unique labelsThe sections to write, in the order you want them.
section_spec[].labelstringYesnon-empty, ≤ 120 charsThe section's heading. Echoed back verbatim.
section_spec[].shapestringYesprose or bulletsDetermines which of the two count fields applies.
section_spec[].sentencesinteger or [min, max]For proseeach value 1–12How many sentences the section takes. Forbidden on a bullets section.
section_spec[].bulletsinteger or [min, max]For bulletseach value 1–12How many bullets the section takes. Forbidden on a prose section.
section_spec[].slotsarray of stringNo≤ 12 entries, each ≤ 240 chars, count within the bullets rangeOne entry per bullet, describing what that bullet must be about.
subject_factsobjectYesserializes to ≤ 120,000 charactersThe only material the summary may assert from. Free-form — the engine does not require any particular shape.
assist_contextobjectNosee assist_contextWho the summary speaks as (persona), your layered instructions and their precedence, and references loaded always. On-demand references are rejected here: nothing on this endpoint can read one.

Exact counts vs. ranges​

A count may be an integer (2) or a [min, max] pair ([2, 3]). Prefer a range wherever the section has as many things to say as the facts support.

This matters more than it looks. A section like "the risks we can see" wants one bullet per risk that is actually present; pinning it to exactly three means that when only two are present, the third has to be made up. A range removes that pressure, and the engine will return fewer real bullets rather than pad to reach a floor.

slots and bullets must agree: with bullets: 3 you send exactly three slots; with bullets: [1, 4] you may send one to four.

Subject types​

subject_type does not change the sections — you control those. It tells the engine what it is reading, so the writing sits at the right altitude.

ValueThe summary is about
patient_chartOne person, across everything on their record.
caseOne case, followed across every visit it has had.
encounterOne visit.
case_riskOne case's risk picture: what the risk is and how firm it is.
clinician_dayOne clinician's own working day, written to them.
customWhatever task_brief says. Use it for a summary none of the five above describes; the honesty rules and the section contract are unchanged.

The audience and voice come from assist_context.persona when you send one, and from the engine's audience-neutral default when you do not.

De-identification requirement​

All request bodies are scanned for identifiers on the way in. Do not include names, dates of birth, SSN-shaped values, medical record numbers, or any field whose key names an identifier (name, dob, email, phone, mrn, patient_id, member_id, ...). A body containing one returns 400 DeIdentificationViolation with field naming the offending path — at any depth inside subject_facts.

Two rules specific to this endpoint:

  • Format your dates before you send them. A field whose whole value is a calendar date ("2026-07-14") is rejected. Send an elapsed count ("days_since_injury": 19) or an already-formatted string ("14 Jul 2026"). Elapsed counts are the better choice: they are what a reader usually wants, and they let repeat calls over unchanged facts produce identical bundles.
  • Prose inside subject_facts is redacted, not rejected. Free-text you include — a note, a quoted answer — legitimately contains phone- and email-shaped spans, so those spans are scrubbed from the text before the model sees them rather than failing the whole call. This applies only to subject_facts. The rest of the body, including section_spec, faces the full reject.

Response​

Success — 200 OK​

A real response, from the request body in the Postman collection fixture for this operation — not a composed example:

{
"sections": [
{
"label": "The story so far",
"lines": [
"A warehouse selector sustained an accepted lumbar strain through lifting nineteen days ago, reporting it nine days after the injury; across three visits since, the case has moved into the subacute stage with the worker currently on modified duty. Composite risk is elevated at a score of 78, driven primarily by pain and fear-avoidance patterns, signalling that psychosocial factors are shaping recovery alongside the mechanical findings."
]
},
{
"label": "Key events",
"lines": [
"Lumbar strain from a lifting mechanism, with reporting delayed nine days after the injury occurred.",
"The visit four days ago found reduced lumbar flexion with no neurological deficit.",
"A follow-up visit is scheduled in three days."
],
"bulleted": true
},
{
"label": "Watch for",
"lines": [
"Fear-avoidance is the second-highest elevated pattern at a score of 74, trailing pain at 78, and together they are likely sustaining the return-to-work barrier score of 62 — probe activity avoidance and pain beliefs at the next visit. No red flags are present and modified duty is in place, but failure to address fear-avoidance now risks chronicity at a stage where the window for intervention is still open."
]
}
],
"subject_type": "case",
"subject_id": "550e8400-e29b-41d4-a716-446655440000",
"model": "claude-sonnet-4-6",
"usage": { "input_tokens": 2220, "output_tokens": 344 },
"request_id": "live-verify-001",
"timestamp": "2026-08-16T08:16:10.354989+00:00"
}

Worth reading the prose above for what the engine does with your facts rather than just the shape. Every figure it quotes is labelled and separated by words, and every time reference is an elapsed count — no numeric dates. Both are hard constraints rather than style: an unlabelled run of numbers and a numeric date are the two shapes the outbound identifier scan rejects, and a rejected section comes back in degraded instead of as prose. Send your facts the same way and there is nothing for the scan to catch.

Response fields​

FieldTypeAlways presentDescription
sectionsarrayYesOne entry per section that was written, in your spec's order.
sections[].labelstringYesYour label, verbatim.
sections[].linesarray of stringYesOne string for a prose section; one per bullet for a bullets section.
sections[].bulletedbooleanOn bullets sectionsPresent and true for a bullets section, absent for prose. Render the bullet marker yourself.
subject_typestringYesEchoed from the request.
subject_idstring | nullYesEchoed from the request; null when you sent none.
modelstringYesThe model that served the call. This is the pricing basis.
usageobject | nullYesinput_tokens / output_tokens, plus cache_read_tokens / cache_write_tokens (prompt tokens served from, or written to, the provider's cache; input_tokens excludes both). null means the count was unavailable — treat it as unknown, never as zero.
degradedarrayNoPresent only when a section could not be produced. See below.
request_idstringYesCorrelation ID. Echo back in support tickets.
timestampstring (ISO 8601)YesServer timestamp at response emission.

A prose section is one string​

lines holds exactly one string for a prose section, even when you asked for two or three sentences — those sentences are inside that one string. Do not write a renderer that assumes one array entry per sentence; there is one entry per section.

degraded — a partial answer that says so​

Generated text is scanned for identifier shapes before it is returned. If a section fails that scan, the engine regenerates once. If the second attempt also fails, the call still returns 200 with the sections that were produced, and names the ones that were not:

{
"sections": [
{ "label": "Key events", "lines": ["..."], "bulleted": true }
],
"degraded": [
{ "label": "The story so far", "reason": "appears to contain a phone number" }
],
"model": "claude-sonnet-4-6",
"usage": { "input_tokens": 1624, "output_tokens": 402 },
"request_id": "8f2c3b1d-5e4a-4a9c-9b1e-2a7f1d3e4c5b",
"timestamp": "2026-08-16T14:02:11.418Z"
}

degraded is absent on a clean call, so its presence is the whole signal. Two things follow for a caller:

  • Check for it. A caller that renders sections blindly shows a summary with a section quietly missing, and cannot tell a short answer from an incomplete one.
  • A degraded call is still billed, for both generation attempts. usage reports the total.

If no section survives, the call fails with 502 UpstreamError rather than returning an empty sections array — a summary with nothing in it is a failed call, and reporting it as a success would leave you storing a blank result with no reason to retry.

Status codes​

CodeMeaningWhen
200OKSummary generated. May carry degraded.
400ValidationErrorMalformed JSON, a missing or invalid field, or a section_spec entry whose shape and count fields disagree. field names the offender.
400DeIdentificationViolationThe body contained an identifier. field names the path.
401UnauthenticatedMissing or invalid API key.
402PaymentRequiredThe consumer is out of credits.
404NotFoundPath does not exist or the method is wrong.
413PayloadTooLargeBody exceeds the 512 KB limit.
415UnsupportedMediaTypeContent-Type is not application/json.
429RateLimitedPer-consumer rate limit exceeded. See Retry-After.
500InternalErrorUnexpected server-side failure. Include request_id when reporting.
502UpstreamErrorThe model call failed, or no section could be generated.
504UpstreamTimeoutGeneration exceeded the engine's budget.

Error envelope​

All non-2xx responses use a uniform envelope. A real one, from a section_spec entry that carried both sentences and bullets:

{
"timestamp": "2026-08-16T08:16:39.060Z",
"error": "ValidationError",
"message": "section_spec[0] is a prose section and must not carry 'bullets' or 'slots'",
"field": "section_spec[0]",
"request_id": "b0229120-b35e-41d4-b67d-d8f1d3bc2c8c"
}

The de-identification rejection is the one most callers meet first, and it names the exact path so you do not have to search your bundle for it. This is a real response to a subject_facts carrying {"patient": {"name": "..."}}:

{
"timestamp": "2026-08-16T08:16:39.571Z",
"error": "DeIdentificationViolation",
"message": "subject_facts.patient.name is not accepted (the engine only receives de-identified inputs)",
"field": "subject_facts.patient.name",
"request_id": "1106c681-72e8-49b1-84bf-016d5d4de43d"
}

Note that it fired on the field name, at depth, inside subject_facts — the scrubbing described under de-identification neutralises identifier-shaped values in your prose, and deliberately does not relax the blocked-name rule. Key your data by what it measures, never by whose it is.

See errors.md for the full code list.

What a call costs​

Variable, like /api/ask/run. The cost depends on the size of the call and the model that served it:

in = input_tokens (or request bytes when usage is null — UTF-8 byte length)
out = output_tokens (or response bytes when usage is null)
credits = max(1, ceil(in × f + cache_write_tokens × f × 1.25
+ cache_read_tokens × f × 0.1 + out × g))

f and g are the model's input and output cost factors. A prompt prefix served from the provider's cache (the engine's rules, your persona, instructions and references, identical from call to call) costs a tenth of the input rate; writing it the first time costs a quarter more.

The two per-model factors are the same ones /api/ask/run uses, published on the developer portal's pricing page; the model on the response tells you which row applied. The exact charge is returned in X-Credits-Used — treat that header as the source of truth for what you were billed, and see credits.md.

Two levers worth knowing. subject_facts size dominates the input side, so sending a trimmed bundle rather than a whole record is the single biggest cost control you have. And a degraded call bills both attempts, which is one more reason to format dates and label numbers before sending them.

For a concrete anchor: the three-section case summary shown above — a 966-byte request producing 2,220 input and 344 output tokens on claude-sonnet-4-6 — was billed 6 credits, and returned in about nine seconds. Note how lopsided that is. The output is a seventh of the input, because the system prompt and your fact bundle are both on the input side, so a bundle twice the size costs close to twice as much no matter how short the summary is.

Examples​

curl​

curl -sS -X POST "https://engine.tripod-health.com/api/assist/summarize" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject_type": "encounter",
"section_spec": [
{ "label": "What happened", "shape": "prose", "sentences": 2 },
{ "label": "What was decided", "shape": "bullets", "bullets": [2, 3] }
],
"subject_facts": {
"visit_number": 3,
"days_since_injury": 19,
"findings": "lumbar flexion improved, straight-leg raise negative",
"work_status": "modified duty continued, 20 lb lifting limit",
"plan": ["continue home exercise", "review imaging when it returns"]
}
}'

Python (requests)​

import os, requests

resp = requests.post(
"https://engine.tripod-health.com/api/assist/summarize",
headers={"apikey": os.environ["ENGINE_API_KEY"]},
json={
"subject_type": "encounter",
"section_spec": [
{"label": "What happened", "shape": "prose", "sentences": 2},
{"label": "What was decided", "shape": "bullets", "bullets": [2, 3]},
],
"subject_facts": {
"visit_number": 3,
"days_since_injury": 19,
"work_status": "modified duty continued, 20 lb lifting limit",
},
},
timeout=90,
)
resp.raise_for_status()
data = resp.json()

for section in data["sections"]:
print(section["label"])
for line in section["lines"]:
print((" - " if section.get("bulleted") else " ") + line)

if data.get("degraded"):
print("incomplete:", [d["label"] for d in data["degraded"]])
print("credits:", resp.headers.get("X-Credits-Used"))

Node.js (native fetch)​

const resp = await fetch("https://engine.tripod-health.com/api/assist/summarize", {
method: "POST",
headers: {
apikey: process.env.ENGINE_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
subject_type: "encounter",
section_spec: [
{ label: "What happened", shape: "prose", sentences: 2 },
{ label: "What was decided", shape: "bullets", bullets: [2, 3] },
],
subject_facts: {
visit_number: 3,
days_since_injury: 19,
work_status: "modified duty continued, 20 lb lifting limit",
},
}),
// Above the engine's own generation budget: a summary that succeeds here and
// is then abandoned by your abort is still billed.
signal: AbortSignal.timeout(90_000),
});
if (!resp.ok) throw new Error(`Engine ${resp.status}: ${await resp.text()}`);
const data = await resp.json();

Timeouts​

Generation takes seconds, not milliseconds, and the engine may spend a second attempt correcting a section. The three-section case summary above returned in about nine seconds on a clean first pass; a wider spec, a larger fact bundle, or a section that needed regenerating all push that up.

Set your client timeout above 60 seconds. A timeout below the engine's own budget is the worst configuration available: the summary is generated, billed, and then discarded unread, and a client that retries on timeout pays for it more than once. Do not size your timeout from the nine seconds above — that is a typical first pass, not a ceiling.

Intended use​

  • Writing a per-record narrative — a person, a case, a visit — from structured data you already hold.
  • Writing a daily or per-shift brief over an aggregate.
  • Any surface where you want one consistent voice across several different kinds of summary, since they all go through one endpoint and one prompt.
  • Regenerating a summary when the underlying facts change. Hash your bundle and skip the call when the hash is unchanged; nothing about the output depends on history, so an unchanged bundle has no new summary to produce.

Mistaken use​

  • Not a chat endpoint. There is no conversation and no history. One request, one summary.
  • Not a general prompt runner. If you want a model's answer to an arbitrary prompt, use /api/ask/run.
  • Not a data source. The engine asserts only what is in subject_facts. It will not look anything up, and a section your bundle does not support comes back saying so rather than filled in.
  • Not a clinical decision. The output is a written restatement of what you sent. It makes no determination and adds no finding.
  • Do not send a whole record and expect it trimmed. The input side of the bill scales with bundle size; select the facts before you send them.
  • Do not send raw ISO dates or unlabelled number runs. A bare date value is a 400, and a run of numbers with nothing but spaces between them fails the outbound scan and costs you a degraded section. Label your numbers.

Debugging​

Checklist when a call fails:

  1. Capture the request_id from the response body (or, if the response has no body from the engine, the X-Request-ID response header). Every support ticket starts here.
  2. Check error before message. error is a stable machine-readable code; message is human-readable and may change.
  3. 400 ValidationError on section_spec[n] — the commonest cause is a shape/count mismatch: a prose section carrying bullets, a bullets section carrying sentences, or a slots array whose length falls outside the bullets range. The field extra names the entry.
  4. 400 DeIdentificationViolation — field names the exact path, including inside subject_facts. Usually a bare calendar date or a key named after an identifier. Fix the bundle; do not retry unchanged.
  5. 402 PaymentRequired — the consumer's credit balance is exhausted. This is not retryable; top up first.
  6. A section is missing from sections — look at degraded. It names the section and why. The usual cause is a fact bundle that pushed the model into writing a bare number run or a numeric date.
  7. 502 UpstreamError — either the model call failed or nothing could be generated safely. Retry with backoff; if it persists for the same bundle, the bundle is the problem, not the engine.
  8. 504 UpstreamTimeout — generation ran past the engine's budget. Usually an oversized subject_facts; trim it.

Rate limits​

Per-consumer limits are enforced at the gateway. Specific numbers depend on your plan; see the rate limits reference. Responses include standard RateLimit-* headers per IETF draft-07.

  • POST /api/ask/run — free-text prompt to a named model. The general-purpose sibling; same variable-cost model.
  • POST /api/encounters/document/draft — transcript to the coded sections of a clinical note. Structured output, not prose -- except for a check-in, which returns a short narrative note.
  • POST /api/nudge/suggest — in-visit prompts from a live transcript window.
  • credits — how variable-cost endpoints are billed.

Change history​

DateChange
2026-10-11assist_context (persona, layered instructions, precedence, always-loaded references); subject_type: "custom" with task_brief; usage reports cache_read_tokens and cache_write_tokens, priced at their own rates.
2026-08-16Initial publication.
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.