Assist Narrative
Status:
betaCategory:trinityassistAdded:2026-08-16Last 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
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/assist/summarize |
| Full URL | https://engine.tripod-health.com/api/assist/summarize |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (generated text varies between calls) |
| Rate-limited | Yes |
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
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | Must 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
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
subject_type | string | Yes | one of patient_chart, case, encounter, case_risk, clinician_day, custom | What the summary is about. Sets the altitude of the writing — see Subject types. |
task_brief | string | With custom only | 1–8,000 chars | What a custom summary is for, in your words. Required with custom; rejected with any other subject type. |
subject_id | string | No | non-empty, ≤ 64 chars | Opaque correlation key, echoed on the response. Never resolved, never stored, never shown to the model. |
clinician_user_id_hash | string | No | non-empty, ≤ 128 chars | Opaque correlation key. Same treatment as subject_id. |
section_spec | array | Yes | 1–12 entries, unique labels | The sections to write, in the order you want them. |
section_spec[].label | string | Yes | non-empty, ≤ 120 chars | The section's heading. Echoed back verbatim. |
section_spec[].shape | string | Yes | prose or bullets | Determines which of the two count fields applies. |
section_spec[].sentences | integer or [min, max] | For prose | each value 1–12 | How many sentences the section takes. Forbidden on a bullets section. |
section_spec[].bullets | integer or [min, max] | For bullets | each value 1–12 | How many bullets the section takes. Forbidden on a prose section. |
section_spec[].slots | array of string | No | ≤ 12 entries, each ≤ 240 chars, count within the bullets range | One entry per bullet, describing what that bullet must be about. |
subject_facts | object | Yes | serializes to ≤ 120,000 characters | The only material the summary may assert from. Free-form — the engine does not require any particular shape. |
assist_context | object | No | see assist_context | Who 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.
| Value | The summary is about |
|---|---|
patient_chart | One person, across everything on their record. |
case | One case, followed across every visit it has had. |
encounter | One visit. |
case_risk | One case's risk picture: what the risk is and how firm it is. |
clinician_day | One clinician's own working day, written to them. |
custom | Whatever 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_factsis 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 tosubject_facts. The rest of the body, includingsection_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
| Field | Type | Always present | Description |
|---|---|---|---|
sections | array | Yes | One entry per section that was written, in your spec's order. |
sections[].label | string | Yes | Your label, verbatim. |
sections[].lines | array of string | Yes | One string for a prose section; one per bullet for a bullets section. |
sections[].bulleted | boolean | On bullets sections | Present and true for a bullets section, absent for prose. Render the bullet marker yourself. |
subject_type | string | Yes | Echoed from the request. |
subject_id | string | null | Yes | Echoed from the request; null when you sent none. |
model | string | Yes | The model that served the call. This is the pricing basis. |
usage | object | null | Yes | input_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. |
degraded | array | No | Present only when a section could not be produced. See below. |
request_id | string | Yes | Correlation ID. Echo back in support tickets. |
timestamp | string (ISO 8601) | Yes | Server 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
sectionsblindly 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.
usagereports 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
| Code | Meaning | When |
|---|---|---|
200 | OK | Summary generated. May carry degraded. |
400 | ValidationError | Malformed JSON, a missing or invalid field, or a section_spec entry whose shape and count fields disagree. field names the offender. |
400 | DeIdentificationViolation | The body contained an identifier. field names the path. |
401 | Unauthenticated | Missing or invalid API key. |
402 | PaymentRequired | The consumer is out of credits. |
404 | NotFound | Path does not exist or the method is wrong. |
413 | PayloadTooLarge | Body exceeds the 512 KB limit. |
415 | UnsupportedMediaType | Content-Type is not application/json. |
429 | RateLimited | Per-consumer rate limit exceeded. See Retry-After. |
500 | InternalError | Unexpected server-side failure. Include request_id when reporting. |
502 | UpstreamError | The model call failed, or no section could be generated. |
504 | UpstreamTimeout | Generation 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:
- Capture the
request_idfrom the response body (or, if the response has no body from the engine, theX-Request-IDresponse header). Every support ticket starts here. - Check
errorbeforemessage.erroris a stable machine-readable code;messageis human-readable and may change. 400 ValidationErroronsection_spec[n]— the commonest cause is a shape/count mismatch: aprosesection carryingbullets, abulletssection carryingsentences, or aslotsarray whose length falls outside thebulletsrange. Thefieldextra names the entry.400 DeIdentificationViolation—fieldnames the exact path, including insidesubject_facts. Usually a bare calendar date or a key named after an identifier. Fix the bundle; do not retry unchanged.402 PaymentRequired— the consumer's credit balance is exhausted. This is not retryable; top up first.- A section is missing from
sections— look atdegraded. 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. 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.504 UpstreamTimeout— generation ran past the engine's budget. Usually an oversizedsubject_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.
Related
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
| Date | Change |
|---|---|
| 2026-10-11 | assist_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-16 | Initial publication. |
| 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. |