Assist Chat
Status:
betaCategory:trinityassistAdded:2026-08-17Last 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
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/assist/chat |
| Full URL | https://engine.tripod-health.com/api/assist/chat |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (generated text varies between calls) |
| Rate-limited | Yes |
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
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | Must 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
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
messages | array | Yes | 1–600 entries | The thread so far, oldest first. |
messages[].role | string | Yes | user or assistant | Who said it. |
messages[].content | string or array | Yes | string ≤ 24,000 chars; array 1–24 blocks | Plain text, or content blocks. |
tools | array | No | ≤ 40 entries, uniquely named | The lookups you are willing to run this turn. |
tools[].tool | string | Yes | ≤ 64 chars, not reserved | The lookup's name. Not name — see below. |
tools[].description | string | Yes | ≤ 1,200 chars | What it returns and when to reach for it. This is prompt surface: write it for the model. |
tools[].input_schema | object | Yes | JSON Schema | The parameters, as a JSON Schema object. |
assist_context | object | No | see assist_context | Your assistant's persona, your layered instructions and their precedence, and its references. |
subject_facts | object | null | No | Pinned context for the whole thread. | |
tier | string | null | No | fast, balanced, advanced | Which capability tier serves the turn. Omit for fast. See Choosing a tier. |
effort | string | null | No | low, medium, high | How hard the tier's model thinks. Omit for the model's own default. |
web_search | boolean | No | default false | Let the engine search the public web while answering. See Web search. |
reference_tokens | boolean | No | default true | Set 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.
| Field | Type | Constraints | Description |
|---|---|---|---|
persona | object | Replaces the engine's default persona. | |
persona.assistant_label | string | 1–80 chars | What the assistant is called. |
persona.voice | string | 1–6,000 chars | Who it is talking to and how. |
precedence | array of string | ≤ 16 labels, each 1–60 chars | Your layer labels, highest first. Rendered as one line after the engine's own rules, which always rank first and cannot be overridden. |
instructions | array | ≤ 24 entries | Your standing rules, in labelled layers. |
instructions[].layer | string | 1–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[].text | string | 1–8,000 chars | The instruction. |
instructions[].cache_breakpoint | boolean | default false; at most 2 per request | End a cached prefix after this instruction. |
references | array | ≤ 60 entries; bodies ≤ 200,000 chars in total | Documents the assistant may use. |
references[].ref | string | 1–120 chars, ^[A-Za-z0-9_.:\-/]+$, unique | Your id for the document. |
references[].layer | string | same rule as instructions[].layer | |
references[].title | string | 1–200 chars | |
references[].summary | string | 0–300 chars | One line for the table of contents. |
references[].load | string | always or on_demand | always sends the body in full; on_demand lists it by ref, title, summary and layer only. |
references[].body | string | 1–40,000 chars | Required 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
surfaceis 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-
surfaceinstructions 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 outsidesurface-- a timestamp in an instruction, say -- breaks that. - Authored text is scrubbed, not rejected. Phone-, email- and SSN-shaped
spans in
persona.voice,instructions[].textand a reference'stitle,summaryandbodyare 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:
| Block | Roles | Shape |
|---|---|---|
text | user, assistant | { "type": "text", "text": "..." } |
tool_use | assistant only | { "type": "tool_use", "id": "...", "tool": "...", "input": { ... } } |
tool_result | user 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_useblock is answered by atool_resultwith 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[].contentandtool_resultblock 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, everytool_useblock'sinput, 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.
Web search
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:
trust | Meaning | How the assistant is told to use it |
|---|---|---|
authoritative | Government, standards bodies, indexed literature, professional societies. | May support a claim directly. |
reputable | Established publishers and institutions with editorial accountability. | May support a claim, attributed. |
open | Anywhere 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. |
unknown | Not 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
| Field | Type | Always present | Description |
|---|---|---|---|
output | string | Yes | The 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_calls | array | Yes | Empty on a plain answer. One entry per lookup requested. |
tool_calls[].id | string | Yes | Echo this as tool_call_id when you return the result. |
tool_calls[].tool | string | Yes | Which of your tools. |
tool_calls[].input | object | Yes | Arguments, matching that tool's input_schema. Validate them anyway. |
stop_reason | string | null | Yes | end_turn on an answer, tool_use when lookups were requested, max_tokens if the answer was cut short. |
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 unavailable — treat it as unknown, never as zero. |
citations | array | Yes | Sources from web search. Empty when the turn did not search. |
citations[].domain | string | Yes | The real hostname. Render this, and key your own logic on it. |
citations[].title | string | Yes | The page title where the provider supplies one; frequently just the domain. |
citations[].url | string | Yes | An expiring provider redirect, not the page's own address. Click-through only. |
citations[].trust | string | Yes | authoritative, reputable, open, or unknown. See the tier table. |
citations[].query | string | Yes | The query the engine was asked to search for. |
web_searches | integer | Yes | Searches this turn ran. 0 unless web_search was set. Each one is surcharged. |
degraded | array | No | Present only when the answer was withheld. See below. |
request_id | string | Yes | Correlation ID. |
timestamp | string (ISO 8601) | Yes | Server 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
| Code | Meaning | When |
|---|---|---|
200 | OK | A turn was produced. May carry degraded. |
400 | ValidationError | Malformed JSON, a missing or invalid field, or a turn structure that does not end with the user or leaves a tool_use unanswered. |
400 | DeIdentificationViolation | The body contained an identifier — commonly a name key or a bare date in a tool input. 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 this route's 4 MB limit. |
429 | RateLimited | Per-consumer rate limit exceeded. See Retry-After. |
502 | UpstreamError | The model call failed, or the turn produced neither text nor a tool call. |
504 | UpstreamTimeout | Generation 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_searcheson the response tells you how many ran, and the current rate is on the developer portal's pricing page under add-on charges. Setweb_searchper 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/summarizeassubject_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
- Capture the
request_id. Every support ticket starts here. 400namingtools[0].name— rename the field totool. Seetool, nevername.400 DeIdentificationViolationon a tool input — almost always a bare calendar date. Change the parameter to a day offset.400 ValidationErrorwith nofield— a cross-field rule, and themessagenames it. The usual causes are the turn structure (the thread does not end with ausermessage, or atool_useis not answered by atool_resultwith the same id in the very next message) and a reserved tool name. Readmessage; it is specific.outputis empty — checktool_calls(the turn wants a lookup) and thendegraded(the answer was withheld). Those are the only two causes.- The model ignores a lookup you offered — read the tool's
descriptionas the model does. It is prompt surface; "when to reach for this" belongs in it. - The model answers as though a refused lookup had run — say so in the
result content, or in an
assist_contextinstruction. Silence about a refusal reads as absence of data. 400namingpersonaorsystem_directives— both were replaced byassist_context.cache_read_tokensstays0across consecutive turns — something before the cache breakpoints changes between calls. Move per-call text into thesurfacelayer.502 UpstreamError— the model call failed, or the turn produced nothing at all. Retry with backoff.- You set
web_searchandweb_searchescame back0— 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 arequest_id. - 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.
Related
POST /api/assist/summarize— the one-shot narrative sibling. Sameassist_context, same honesty rules, no thread.POST /api/ask/run— free-text prompt to a named model.- credits — how variable-cost endpoints are billed.
Change history
| Date | Change |
|---|---|
| 2026-10-11 | assist_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-29 | tier 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-13 | Web 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-17 | Initial publication. |
| 2026-10-14 | The engine assigns every request ID; an X-Request-ID request header is not used. |