Loading documentation

Access required

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

Sign out
Skip to main content

Assist Chat

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

Summary​

Answer one turn of a conversation. You send the thread so far as attributed turns; the engine returns the next assistant turn — either an answer, or a request to run one or more of the lookups you offered it.

Two properties define this endpoint, and neither is negotiable:

  • It is stateless. Nothing about a conversation is stored here. You hold the thread and re-send it each turn.
  • It never runs one of your tools. When the answer needs one of your lookups, the response says which ones and stops. You run them, with your own credentials and your own authorization, and send the results back on the next call.

There is exactly one tool the engine runs itself: web search, off unless you ask for it with web_search: true. It is the exception that fits the rule rather than breaking it — it reaches only the public web, so no credential of yours is involved and no authorization decision of yours is bypassed. See Web search.

Cost is variable — it scales with how much the model reads and writes, plus a flat surcharge per web search if the turn ran one (see What a call costs).

Endpoint​

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

Purpose​

Use this when a person wants to ask questions about data you hold, in their own words, over several turns.

The division of responsibility mirrors /api/assist/summarize: you own the context and the capabilities, the engine owns the writing and the honesty rules. Concretely, you supply the conversation, the pinned context, the list of lookups available, and your assist_context -- who your assistant is, your standing rules in labelled layers, and the references it may read; the engine decides what to say, which lookup to reach for, and — the part that matters most — what to do when it does not have enough to answer. It says so, and names the lookup that would settle it, rather than estimating.

Why the engine does not run your tools​

A tool loop that ran here would mean the engine calling back into your API with your users' credentials. That inverts the trust direction: the authorization decision for "may this person read this record" belongs in your system, on the same middleware every other request to that endpoint passes through.

The cost of that choice is real and you should size for it: a turn that uses two lookups is three billed round-trips, not one. It is the price of the security model, and it is the reason the fast tier is this endpoint's default.

Read that rule precisely, because web search does not violate it. The rule is not "the engine never acts" — it is "the engine never acts as you." A search touches nothing you own, presents no credential of yours, and answers a question about the world rather than about your data, so running it here costs you nothing the rule was protecting.

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.

Body​

{
"messages": [
{ "role": "user", "content": "How many visits did I complete last month?" }
],
"tools": [
{
"tool": "listMyEncounters",
"description": "Read-only lookup. List the asking user's encounters with status, purpose and whether they are signed.",
"input_schema": {
"type": "object",
"properties": {
"unsignedOnly": { "type": "boolean", "description": "Restrict to visits awaiting signature." }
},
"required": []
}
}
],
"assist_context": {
"persona": {
"assistant_label": "Clinic Assistant",
"voice": "You are talking with a clinician inside their own workspace. They are a professional; write to them as one."
},
"precedence": ["surface", "boundaries", "role"],
"instructions": [
{ "layer": "boundaries", "text": "The clinician makes every clinical decision.", "cache_breakpoint": true },
{ "layer": "role", "text": "The reader is a physician; clinical shorthand is fine." },
{ "layer": "surface", "text": "You are strictly read-only. You have 1 lookup available this turn." }
],
"references": [
{ "ref": "protocol/escalation", "layer": "governing_documents", "title": "Escalation protocol",
"summary": "When a case moves to the medical director", "load": "on_demand" }
]
},
"subject_facts": { "caseload_active": 34 },
"web_search": false
}

Body fields​

FieldTypeRequiredConstraintsDescription
messagesarrayYes1–600 entriesThe thread so far, oldest first.
messages[].rolestringYesuser or assistantWho said it.
messages[].contentstring or arrayYesstring ≤ 24,000 chars; array 1–24 blocksPlain text, or content blocks.
toolsarrayNo≤ 40 entries, uniquely namedThe lookups you are willing to run this turn.
tools[].toolstringYes≤ 64 chars, not reservedThe lookup's name. Not name — see below.
tools[].descriptionstringYes≤ 1,200 charsWhat it returns and when to reach for it. This is prompt surface: write it for the model.
tools[].input_schemaobjectYesJSON SchemaThe parameters, as a JSON Schema object.
assist_contextobjectNosee assist_contextYour assistant's persona, your layered instructions and their precedence, and its references.
subject_factsobject | nullNoPinned context for the whole thread.
tierstring | nullNofast, balanced, advancedWhich capability tier serves the turn. Omit for fast. See Choosing a tier.
effortstring | nullNolow, medium, highHow hard the tier's model thinks. Omit for the model's own default.
web_searchbooleanNodefault falseLet the engine search the public web while answering. See Web search.
reference_tokensbooleanNodefault trueSet false if you do not resolve [[kind:handle]] reference tokens for your reader; the model is then never taught to write them.

persona and system_directives are no longer accepted: sending either returns 400 ValidationError naming the field. Both are replaced by assist_context.

Choosing a tier​

You name a capability tier, not a model id — unlike /api/ask/run, where you name the exact model. Assist is a product surface, so the set is closed: fast, balanced, advanced, in rising order of capability and price.

The engine maps each tier to a specific model and moves a tier to a newer model as better ones ship, so a tier is a stable name for "this much capability", not a promise of one model. The current mapping is on the Models page of the developer portal. The model on every response is always the one that served the call, and it is the pricing basis.

Omit tier and you get fast, which is right for most single-lookup turns. Raise it when a turn needs to chain reasoning across more than one lookup round — deciding what to fetch second based on what came back first is the thing fast does not do well.

assist_context​

How you describe your assistant. Everything in it is optional, and the engine attaches no meaning to your labels -- it validates the shape, renders it into the prompt in a fixed place after its own rules, and stores nothing. The same object is accepted, with the per-endpoint differences noted on each page, by /api/assist/summarize, /api/nudge/helper, /api/nudge/suggest, /api/recordability/*, /api/work_relatedness/analyze, /api/case_reflection/questions, /api/extract/document, /api/phenotype/classify and the instrument score routes.

FieldTypeConstraintsDescription
personaobjectReplaces the engine's default persona.
persona.assistant_labelstring1–80 charsWhat the assistant is called.
persona.voicestring1–6,000 charsWho it is talking to and how.
precedencearray of string≤ 16 labels, each 1–60 charsYour layer labels, highest first. Rendered as one line after the engine's own rules, which always rank first and cannot be overridden.
instructionsarray≤ 24 entriesYour standing rules, in labelled layers.
instructions[].layerstring1–40 chars, ^[a-z][a-z0-9_.]*$The layer this text belongs to; it is rendered as a header above the text so the model can map precedence onto it.
instructions[].textstring1–8,000 charsThe instruction.
instructions[].cache_breakpointbooleandefault false; at most 2 per requestEnd a cached prefix after this instruction.
referencesarray≤ 60 entries; bodies ≤ 200,000 chars in totalDocuments the assistant may use.
references[].refstring1–120 chars, ^[A-Za-z0-9_.:\-/]+$, uniqueYour id for the document.
references[].layerstringsame rule as instructions[].layer
references[].titlestring1–200 chars
references[].summarystring0–300 charsOne line for the table of contents.
references[].loadstringalways or on_demandalways sends the body in full; on_demand lists it by ref, title, summary and layer only.
references[].bodystring1–40,000 charsRequired for always. Must be absent for on_demand here: your own lookup tool serves it, and the model asks for it by ref.

Three rules worth knowing before you build on it:

  • The layer surface is reserved for what changes per call -- how many lookups you offered this turn, a reminder that this is the last round. It always renders after everything else, whatever its position in the list, and it cannot be a cache breakpoint.
  • Stable content is cached. The engine's rules, your persona, your non-surface instructions and your references form a prefix that is identical from one call to the next, and the model provider serves it from cache at a fraction of the input price (see What a call costs). Anything that varies per call outside surface -- a timestamp in an instruction, say -- breaks that.
  • Authored text is scrubbed, not rejected. Phone-, email- and SSN-shaped spans in persona.voice, instructions[].text and a reference's title, summary and body are redacted before the model sees them. Labels, refs and the persona label are structural and face the full reject.

Without a persona, the engine uses its own audience-neutral default.

Reserved tool names​

Two names are reserved and rejected with 400 ValidationError whether or not you set web_search: web_search itself, and any name beginning engine_. Both name tools the engine runs, and a collision would either be rejected by the model layer or — worse, because it is silent — shadow the engine's tool, so a search request would come back to you to execute and you could not.

They are rejected unconditionally rather than only when web search is on, because a name that is legal on some calls is a name you discover is illegal in production.

The whole conversation (messages + subject_facts) must serialize to ≤ 1,400,000 characters. That bound exists because the input side is what grows silently as a thread runs long, and it is the side that dominates the bill.

tool, never name​

Every request body is scanned for identifiers, and a field called name at any depth is rejected — it is the single key most likely to carry a person's name. So a tool is named by a field called tool, in both tool definitions and tool_use blocks. The engine adapts to the underlying model's own shape internally. Send name and you get a 400 DeIdentificationViolation before anything else is looked at.

Content blocks​

content may be a plain string, or an array of blocks. Blocks exist so you can replay a turn in which the model asked for a lookup:

BlockRolesShape
textuser, assistant{ "type": "text", "text": "..." }
tool_useassistant only{ "type": "tool_use", "id": "...", "tool": "...", "input": { ... } }
tool_resultuser only{ "type": "tool_result", "tool_call_id": "...", "content": "...", "is_error": false }

Send back the id, tool and input exactly as the previous response gave them to you. content is the result, serialized however you like — JSON is the usual choice. Set is_error when the lookup ran and failed, or when you refused to run it, so the model says so rather than answering as though the data were merely absent.

The tool round-trip​

Two rules, both validated, and both of which produce a silently wrong answer rather than an error if you leave them to the model layer:

  • The last message is from user. It is either a fresh question or the results of the lookups last requested.
  • Every tool_use block is answered by a tool_result with the same id, in the message immediately after it. An unanswered call is dropped and the model answers as though the lookup never ran.

Results are blocks in the message list rather than a side array precisely because of that second rule. A side array can only ever describe the most recent round, so a conversation with two lookup rounds would have nowhere to put the first one's results.

A full two-lookup exchange is therefore three calls to this endpoint. See Examples.

subject_facts vs. a message​

Pinned context goes in subject_facts, not in a user turn. It is not something anybody said, and putting it in a turn lets the model attribute it to the person and quote it back as something they told it. It is also re-sent every turn — the endpoint is stateless, so "pinned" means "assembled once", not "transmitted once".

De-identification requirement​

Same contract as every engine endpoint, with one deliberate softening.

  • Free text is redacted, not rejected. messages[].content and tool_result block contents are what a person typed and what your endpoint returned. Phone-, email- and SSN-shaped spans in them are scrubbed before the model sees them, and the call proceeds. Rejecting a user's own sentence because it contains a phone number would be the API telling them their question is malformed.
  • Everything else faces the full reject. tools, every tool_use block's input, and any key named after an identifier anywhere in the body.

The consequence to design around: a tool parameter whose whole value is a calendar date ("2026-07-14") is rejected. A tool input is structured data you composed, not prose someone spoke. Define date parameters as day offsets or counts (fromDaysAgo, withinDays) and resolve them to real dates on your side.

The message you get names date of birth whatever the parameter is called, which is worth knowing before it confuses someone at three in the morning:

{
"error": "DeIdentificationViolation",
"message": "messages[1].content[0].input.since appears to be a precise date of birth (send age_bracket instead)",
"field": "messages[1].content[0].input.since"
}

That was a parameter called since, not a date of birth. The scanner matches the shape, not the field's meaning; trust field over the prose.

Send "web_search": true and the engine adds one tool of its own to the turn. The model decides whether it needs it — most turns do not — and when it does, the engine runs the search itself, reads the results, and answers. You get the answer and the sources in a single response. There is no round-trip to run, and web_search never appears in tool_calls.

Omit the field and nothing about the turn changes: no tool is offered, the prompt does not mention the web, and nothing is surcharged.

What it is for​

Questions whose answer lives in the world rather than in your data — a regulation's text, a standard's current revision, an agency deadline. The assistant's own training has a cutoff and no citation; a search gives it a current answer it can point at.

It is not retrieval over your records. It searches the public web and nothing else.

Sources come back tiered​

Every source is graded by editorial gatekeeping — who can publish on it — rather than by search ranking:

trustMeaningHow the assistant is told to use it
authoritativeGovernment, standards bodies, indexed literature, professional societies.May support a claim directly.
reputableEstablished publishers and institutions with editorial accountability.May support a claim, attributed.
openAnywhere a reader can post or edit — forums, wikis, social.A lead only. Must be corroborated against a gate-kept source before it reaches the answer.
unknownNot on the list.Treated as open.

unknown is the common case and it is deliberate: the list is not exhaustive and never will be, so an unrecognized host fails closed to the weakest tier rather than being trusted by default. Grade on trust, not on whether the domain looks respectable to you — a consultancy's explainer and a regulator's own text both read as authoritative to a person skimming, and only one of them is.

Contested topics​

Where a question is genuinely disputed, the assistant is required to present the sides fairly and take no position — including when its sources lean one way, which authoritative sources routinely do. In practice it will often decline to search a contested policy question at all and answer from general knowledge, because searching one surfaces whichever side is better optimized rather than whichever is better founded.

What bounds it​

  • The query is de-identified before it is sent. It passes the same scan every inbound field passes. A query that fails it is not sent, and the turn is answered without searching rather than failing.
  • Nothing of your conversation is sent to the search provider beyond that gated query — no thread, no subject_facts, no tool results.
  • Two searches per turn, maximum, and the tool is withdrawn before the final pass, so a turn cannot loop.
  • Queries are never logged, on our side. They come back to you on each citation, where they disclose nothing you did not send.

Example​

curl -sS -X POST "https://engine.tripod-health.com/api/assist/chat" \
-H "apikey: $ENGINE_API_KEY" -H "Content-Type: application/json" \
-d '{
"messages": [{ "role": "user",
"content": "How long does OSHA require an employer to retain the 300 log, and where is that requirement written?" }],
"web_search": true
}'
{
"output": "OSHA requires employers to retain the 300 Log for five years following the end of the calendar year it covers. This requirement is written in 29 CFR 1904.33 (found at ecfr.gov, which is the authoritative source for the Code of Federal Regulations).",
"tool_calls": [],
"stop_reason": "end_turn",
"model": "claude-haiku-4-5-20251001",
"usage": { "input_tokens": 4581, "output_tokens": 130 },
"citations": [
{
"title": "ecfr.gov",
"url": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQFkY6nYN1CZ…",
"domain": "ecfr.gov",
"trust": "authoritative",
"query": "OSHA 300 log retention requirement text"
},
{
"title": "cornell.edu",
"url": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQEP9OMRKDi8…",
"domain": "cornell.edu",
"trust": "reputable",
"query": "OSHA 300 log retention requirement text"
},
{
"title": "grinnellmutual.com",
"url": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQFl49eP8eFQ…",
"domain": "grinnellmutual.com",
"trust": "unknown",
"query": "OSHA 300 log retention requirement text"
}
],
"web_searches": 1,
"request_id": "1d9a2110-112a-4d4a-a806-1437a908cf54",
"timestamp": "2026-09-13T01:44:38.973454+00:00"
}

That call took 4.9 seconds and cost 165 credits: 162 for the one search, 3 for the tokens. Note that the answer leans on ecfr.gov and not on the two lower-tier sources that came back alongside it — the tiering is doing its job, and the weaker sources are still returned so you can see what was considered.

url is a redirect issued by the search provider, not the page's own address, and the redirects expire. Render domain as the label and treat url as a click-through with a shelf life; do not store it as a durable reference.

Response​

Success — 200 OK​

A request for a lookup — the first half of the round-trip in Examples:

{
"output": "",
"tool_calls": [
{ "id": "toolu_01E4ynpSMfDTerkmBXBDfYBU", "tool": "listMyEncounters", "input": { "unsignedOnly": true } }
],
"stop_reason": "tool_use",
"model": "claude-haiku-4-5-20251001",
"usage": { "input_tokens": 1108, "output_tokens": 59 },
"request_id": "4c730dac-b855-423d-80e6-d60b850eb6d0",
"timestamp": "2026-08-17T03:16:34.084009+00:00"
}

Note the empty output. A turn that goes straight to a lookup usually says nothing first — the model reaches for the tool rather than narrating that it is about to. Do not render that as an answer, and do not treat it as a fault; check tool_calls before you check output.

An answer, after that lookup's result was sent back:

{
"output": "You have two unsigned visits. The oldest is six days old.",
"tool_calls": [],
"stop_reason": "end_turn",
"model": "claude-haiku-4-5-20251001",
"usage": { "input_tokens": 1182, "output_tokens": 16 },
"request_id": "1d9eb829-7776-4dbd-9bda-c4e35bc6905d",
"timestamp": "2026-08-17T03:16:44.249198+00:00"
}

The input side is the whole thread: 1,108 tokens to ask, 1,182 to answer the same question one round later. See What a call costs.

Response fields​

FieldTypeAlways presentDescription
outputstringYesThe assistant's text. Legitimately empty when the turn went straight to a lookup — and also when the answer was withheld, which degraded is what distinguishes.
tool_callsarrayYesEmpty on a plain answer. One entry per lookup requested.
tool_calls[].idstringYesEcho this as tool_call_id when you return the result.
tool_calls[].toolstringYesWhich of your tools.
tool_calls[].inputobjectYesArguments, matching that tool's input_schema. Validate them anyway.
stop_reasonstring | nullYesend_turn on an answer, tool_use when lookups were requested, max_tokens if the answer was cut short.
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 unavailable — treat it as unknown, never as zero.
citationsarrayYesSources from web search. Empty when the turn did not search.
citations[].domainstringYesThe real hostname. Render this, and key your own logic on it.
citations[].titlestringYesThe page title where the provider supplies one; frequently just the domain.
citations[].urlstringYesAn expiring provider redirect, not the page's own address. Click-through only.
citations[].truststringYesauthoritative, reputable, open, or unknown. See the tier table.
citations[].querystringYesThe query the engine was asked to search for.
web_searchesintegerYesSearches this turn ran. 0 unless web_search was set. Each one is surcharged.
degradedarrayNoPresent only when the answer was withheld. See below.
request_idstringYesCorrelation ID.
timestampstring (ISO 8601)YesServer timestamp at response emission.

citations and web_searches are always present, at [] and 0, so you can read them without a guard on every turn.

degraded — the answer was withheld​

Generated text is scanned for identifier shapes before it is returned. If it fails, the engine regenerates once; if the second attempt also fails, the call still returns 200, with output empty and the reason named:

{
"output": "",
"tool_calls": [],
"stop_reason": "end_turn",
"degraded": [{ "label": "answer", "reason": "appears to contain a phone number" }],
"model": "claude-haiku-4-5-20251001",
"usage": { "input_tokens": 2410, "output_tokens": 96 },
"request_id": "…",
"timestamp": "…"
}

This is deliberately not an error. A 502 mid-conversation is a thread with nowhere to go: the person's question is already on screen, and there is no state to recover. So the turn comes back, your surface says the reply could not be shown, and the conversation continues.

Two things follow. Check for degraded before rendering output, or an empty answer looks like a bug rather than a withheld one. And a degraded call is billed for both attempts — usage reports the total.

Any tool_calls on a degraded turn are still returned: they are structured objects the scan never looked at, and dropping them would turn a recoverable turn into a dead one.

In practice this is a backstop you should rarely see. The assistant's own rules already refuse to write an identifier, so asked point-blank to repeat a phone number it declines in ordinary prose and the scan never has anything to catch. degraded is what covers the case where those rules are circumvented — build the branch, but do not expect it in normal traffic.

Status codes​

CodeMeaningWhen
200OKA turn was produced. May carry degraded.
400ValidationErrorMalformed JSON, a missing or invalid field, or a turn structure that does not end with the user or leaves a tool_use unanswered.
400DeIdentificationViolationThe body contained an identifier — commonly a name key or a bare date in a tool input. 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 this route's 4 MB limit.
429RateLimitedPer-consumer rate limit exceeded. See Retry-After.
502UpstreamErrorThe model call failed, or the turn produced neither text nor a tool call.
504UpstreamTimeoutGeneration exceeded the engine's budget.

See errors.md for the full code list.

field is not always there​

A field-level rejection names the offender in a field, and so does every DeIdentificationViolation:

{ "error": "ValidationError", "message": "Field 'messages[0].role' must be one of: user, assistant", "field": "messages[0].role" }

A cross-field rejection — one where the rule spans more than one field, like the turn-structure rules or a reserved tool name — carries no field, because there is no single one to name. The message still says exactly what is wrong:

{ "error": "ValidationError", "message": "'tools[0].tool' is 'web_search', which is reserved by the engine: the name 'web_search' and any name starting with 'engine_' name engine-run tools and cannot be used for a tool the caller executes" }

So read message always, and treat field as a convenience when present, never as something to branch on.

401 and 404 are produced by the gateway before the request reaches the engine, so they carry only message and request_id — no error or timestamp field. A missing key reads No API key found in request; a wrong method reads no Route matched with those values.

What a call costs​

Variable, and computed as for /api/assist/summarize, plus one addition this endpoint alone carries:

credits = max(1, ceil(input_tokens × f
+ cache_write_tokens × f × 1.25
+ cache_read_tokens × f × 0.1
+ output_tokens × g))
+ web_searches × web_search_surcharge

f and g are the input and output cost factors read from the row for the model on the response, which is why tier moves the price. A prompt prefix served from the provider's cache costs a tenth of the input rate; writing it the first time costs a quarter more. Without web_search, the last term is always zero and the formula is the summarize one.

Chat defaults to the fast tier where the narrative endpoint uses balanced: a summary is written once and then read as fact, while a chat answer is immediately challengeable, so the spend follows the artifact that persists.

Three levers, and the first two are not the ones people reach for:

  • The input side dominates, and it grows every turn. A ten-turn thread re-sends nine turns of history plus your pinned context on the tenth call. Cap the thread, or summarize its older half into subject_facts.
  • Tool rounds multiply the turn. Two lookups is three billed calls, each one carrying the whole thread. Cap the automatic rounds per user message — four is a reasonable ceiling — so one runaway question cannot spend a day's budget.
  • A web search costs far more than the turn around it. The surcharge is a flat per-search fee covering a real upstream charge, and on a short turn it dominates the bill several times over — the worked example above is 162 credits of search against 3 of tokens. web_searches on the response tells you how many ran, and the current rate is on the developer portal's pricing page under add-on charges. Set web_search per call, from whether the question actually needs the world, rather than switching it on for every turn.

Examples​

A full tool round-trip (curl)​

The exchange below is the one the Response examples came from; the ids and figures there are what these calls actually returned.

1 — the question.

curl -sS -X POST "https://engine.tripod-health.com/api/assist/chat" \
-H "apikey: $ENGINE_API_KEY" -H "Content-Type: application/json" \
-d '{
"messages": [{ "role": "user", "content": "Any visits still unsigned?" }],
"tools": [{
"tool": "listMyEncounters",
"description": "Read-only lookup. The asking user'\''s visits, with whether each is signed.",
"input_schema": {
"type": "object",
"properties": { "unsignedOnly": { "type": "boolean", "description": "Only unsigned visits." } },
"required": []
}
}]
}'

Returns stop_reason: "tool_use" and one entry in tool_calls.

2 — you run the lookup yourself, with your own credentials and your own authorization. The engine is not involved.

3 — send the result back, with the assistant turn that asked for it:

curl -sS -X POST "https://engine.tripod-health.com/api/assist/chat" \
-H "apikey: $ENGINE_API_KEY" -H "Content-Type: application/json" \
-d '{
"messages": [
{ "role": "user", "content": "Any visits still unsigned?" },
{ "role": "assistant", "content": [
{ "type": "tool_use", "id": "toolu_01E4ynpSMfDTerkmBXBDfYBU", "tool": "listMyEncounters",
"input": { "unsignedOnly": true } }
]},
{ "role": "user", "content": [
{ "type": "tool_result", "tool_call_id": "toolu_01E4ynpSMfDTerkmBXBDfYBU",
"content": "{\"unsigned\":2,\"oldest_age_days\":6}" }
]}
],
"tools": [{ "tool": "listMyEncounters", "description": "…", "input_schema": { "type": "object", "properties": {}, "required": [] } }]
}'

Returns the answer.

Node.js (native fetch)​

async function turn(body: unknown) {
const resp = await fetch("https://engine.tripod-health.com/api/assist/chat", {
method: "POST",
headers: {
apikey: process.env.ENGINE_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
// Above the engine's own budget: a turn that succeeds here and is then
// abandoned by your abort is still billed.
signal: AbortSignal.timeout(60_000),
});
if (!resp.ok) throw new Error(`Engine ${resp.status}: ${await resp.text()}`);
return resp.json();
}

Keep tools in the request on every turn of a thread, including the one carrying tool results. The endpoint is stateless: a turn that omits them is a turn where the model has no lookups.

Timeouts​

A turn is seconds, not milliseconds, and the engine may spend a second attempt correcting an answer. Set your client timeout above 60 seconds. A timeout below the engine's budget is the worst configuration available — the turn is generated, billed, and then discarded unread, and a client that retries on timeout pays for it more than once.

Intended use​

  • A question-and-answer surface over data you already hold, where the person asks in their own words.
  • Follow-up questions about something you already generated: pass the same fact bundle you gave /api/assist/summarize as subject_facts, and "which ones?" is answered from the same material that produced the summary rather than by a fresh lookup.
  • Any assistant where the retrieval has to run under the end user's own permissions.

Mistaken use​

  • Not a session. Nothing is remembered. If you do not send the thread, there is no thread.
  • Not an agent runtime. The engine will not call your API, chain lookups on its own, or retry one that failed. It asks; you decide and execute.
  • Not a way to grant data access. A tool the model asks for is a request. If your user may not read something, do not run the lookup — and tell the model the lookup was refused, so it answers around the gap instead of assuming it succeeded.
  • Not for a one-shot summary. Use /api/assist/summarize: it is cheaper, it has an output contract, and it does not need a thread.
  • Not a general prompt runner. Use /api/ask/run.

Debugging​

  1. Capture the request_id. Every support ticket starts here.
  2. 400 naming tools[0].name — rename the field to tool. See tool, never name.
  3. 400 DeIdentificationViolation on a tool input — almost always a bare calendar date. Change the parameter to a day offset.
  4. 400 ValidationError with no field — a cross-field rule, and the message names it. The usual causes are the turn structure (the thread does not end with a user message, or a tool_use is not answered by a tool_result with the same id in the very next message) and a reserved tool name. Read message; it is specific.
  5. output is empty — check tool_calls (the turn wants a lookup) and then degraded (the answer was withheld). Those are the only two causes.
  6. The model ignores a lookup you offered — read the tool's description as the model does. It is prompt surface; "when to reach for this" belongs in it.
  7. The model answers as though a refused lookup had run — say so in the result content, or in an assist_context instruction. Silence about a refusal reads as absence of data.
  8. 400 naming persona or system_directives — both were replaced by assist_context.
  9. cache_read_tokens stays 0 across consecutive turns — something before the cache breakpoints changes between calls. Move per-call text into the surface layer.
  10. 502 UpstreamError — the model call failed, or the turn produced nothing at all. Retry with backoff.
  11. You set web_search and web_searches came back 0 — usually correct. The model decides per turn, and it will not search a question it can already answer, nor a contested one where searching would surface whichever side is better optimized. A search that was attempted and failed also answers the turn without citations rather than failing it, so a run of zeroes on questions that clearly need the web is worth reporting with a request_id.
  12. A source you expected to be trusted came back unknown — the tier list is not exhaustive and unrecognized hosts fail closed. Tell us the domain; it is tunable without a release.

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.

Change history​

DateChange
2026-10-11assist_context (persona, layered instructions, precedence, references) replaces persona and system_directives, which now return 400. usage reports cache_read_tokens and cache_write_tokens, and cached prompt tokens are priced at their own rates. Documented effort and reference_tokens; corrected the message (600), thread (1,400,000 characters) and body (4 MB) limits.
2026-09-29tier values are now the provider-neutral fast, balanced, advanced; haiku, sonnet, opus and opus5 are no longer accepted. Each tier's current model is listed on the developer portal's Models page.
2026-09-13Web search (web_search, citations, web_searches) and its per-search surcharge. Documented tier and persona, and the reserved tool names. Cross-field rejections now carry a specific message.
2026-08-17Initial publication.
2026-10-14The engine assigns every request ID; an X-Request-ID request header is not used.