API reference
Chat
POST /v1/chat is the endpoint you will spend all your time on. One question, one turn, one
response document.
POST /v1/chat
Request
curl -X POST https://api.trytether.ge/v1/chat \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "X-Tether-Scope-Id: 9" \
-H "X-Tether-User-Id: 431" \
-H "Content-Type: application/json" \
-d '{
"prompt": "how many tickets are still open?",
"sessionId": "0f2c8b41-6d3e-4a19-9c77-1b2a3e4f5d60"
}'
| Field | Type | Default | Notes |
|---|---|---|---|
prompt | string | — | Required. Empty or whitespace returns 400. |
sessionId | uuid | generated | Echo the previous response's session_id to continue a thread. A guest may continue only their own; see Continuing a conversation. |
includeContext | boolean | true | Whether pre-turn context assembly runs. |
capabilityMode | "Standard" | "Deep" | "Standard" | Deep costs materially more and is clamped to Standard on plans that do not allow it. |
enableWriteActions | boolean | false | Lets this turn stage writes. Off by default. |
imageUrl | string | — | HTTPS URL to a publicly fetchable image. |
imageBase64 | string | — | Raw base64 bytes, no data: prefix. Mutually exclusive with imageUrl. |
imageMimeType | string | — | Required with imageBase64, and must match the bytes. |
organizationId, locationId | integer | — | Ignored. Scope comes from the credential. |
Headers
| Header | Notes |
|---|---|
X-Api-Key | Required. See Authentication. |
X-Tether-Scope-Id | Narrows the turn to one site, store or client. |
X-Tether-User-Id | Who is asking, for memory and for the approval trail. |
X-Tether-Explain | Adds meta.explain to the response. Secret keys only. |
X-Tether-Customer-Token | The signed-in end customer, as a token your server signed. See Verified customers. |
X-Tether-Upstream-Token | The signed-in person's own session token for your imported API, used as your userToken mode says. Not verified, never stored. See Calling your API as the signed-in user. |
Asking why a turn did what it did
Send X-Tether-Explain: 1 and the response carries an extra meta.explain object describing
the turn's own record of itself: the route it took, how many tools were offered against how
many are registered, which ran, the tokens and the estimated cost.
{
"explain": {
"request_id": "…",
"route": "analysis",
"result_path": "ok",
"model": "…",
"schema_valid": true,
"tools_registered": 4,
"tools_offered": 2,
"tools_called": ["list_orders"],
"cost": { "prompt_tokens": 412, "completion_tokens": 88, "estimated_usd": 0.00214 },
"timing_ms": { "context": 12, "history": 4, "model": 1610, "validation": 3, "total": 1840 },
"memory": { "scope": "customer", "history_messages": 2, "preferences": ["language"] },
"routing": { "intent": "data_question", "kind": "analysis", "matched_alias": "orders" },
"retrieval": { "chunks": 0, "context_cache_hit": true }
}
}
Every turn carries "reply_language": { "customer": "ka-Latn", "reply": "ka-Latn", "source": "detected", "rule": "Reply language: …" }: the language form Tether read in the message, the one
the turn was told to answer in, why (preference, pack or detected), and the exact rule its
prompt carried. memory says whose memory the turn used (customer, session for an unverified
visitor, user for staff; see Context and memory),
and, when it loaded them, how many prior messages and which preference keys.
A turn that was handed to a person also carries "handoff": { "trigger": …, "language": … }.
tools_offered counts your catalog's tools; the engine's own tether_handoff is not one of them,
though it appears in tools_called when the model used it.
tools_offered is the field worth knowing about. A turn that called nothing because nothing
was offered is a routing outcome; a turn that called nothing although tools were offered is
a description outcome. Nothing else on the response separates them.
Response
200 with the block document. content_type and version are constants on every response.
{
"content_type": "application/vnd.tether.blocks.v1+json",
"version": "tether.blocks.v1",
"title": "Open tickets",
"blocks": [
{ "type": "kpi", "id": "headline", "items": [{ "label": "Open", "value": 7 }] },
{ "type": "text", "id": "summary", "variant": "body",
"markdown": "Seven tickets are still open. The oldest has been open for 41 minutes." }
],
"meta": {
"time_range": { "start": "2026-07-21T00:00:00Z", "end": "2026-08-20T00:00:00Z" },
"customer_language": "en",
"reply_language": "en"
},
"session_id": "0f2c8b41-6d3e-4a19-9c77-1b2a3e4f5d60",
"response_id": "resp_9f1c…",
"pending_actions": []
}
| Field | Notes |
|---|---|
blocks | At least one. Every block has a type and a stable id — see Block types. |
meta | Server-owned. meta.time_range is always written by Tether, never taken from the model. meta.customer_language is the language form Tether read in the prompt, without the model: ka (Georgian script), ka-Latn (Georgian typed in Latin letters), ru, en, other or unknown. meta.reply_language is the form the answer was asked for, which differs when the customer asked for a language or your domain pack sets one. Both are on every turn, streamed ones included, so you can count how often answers match the customer. meta.knowledge lists the knowledge documents the turn drew on, by name, and is absent when it drew on none. |
title | Optional. Present when the model produced one. |
session_id | Echo it on the next request to continue the conversation. |
response_id | Provider-side conversation handle. Optional to use; Tether falls back to its own persisted value. |
pending_actions | Writes staged by this turn. Empty on a read-only turn, which is most of them. |
handoff | Present only when the turn was handed to a person: trigger (customer_request or agent), your contact message and its language. See Handoff to a person. |
Continuing a conversation
{ "prompt": "which one is oldest?", "sessionId": "0f2c8b41-6d3e-4a19-9c77-1b2a3e4f5d60" }
History is scoped by tenant, owner and session: the last 10 messages within a 7-day
window (AI:Policy:HistoryWindowDays). Omitting sessionId starts a new thread, and the
generated id comes back on the response.
A guest's thread is continued only by the guest it was opened for. A verified customer's belongs to
that customer; an unverified visitor's must be one Tether issued to an unverified turn, so a guest
without a token always starts by omitting sessionId. Naming anyone else's, or an id Tether never
issued, is refused 403 session_not_yours before any history is read, with error.reason
other_customer, customer_session, anonymous_session or not_issued. Drop the id and start a
new thread. A visitor who signs in mid-conversation starts a new one too. Staff turns
(X-Tether-User-Id, no customer token) are not bound and may choose their own ids.
Staging a write
Writes need two things: a tool registered is_write, and enableWriteActions on the request.
{ "prompt": "void ticket T-4417", "enableWriteActions": true }
{
"blocks": [
{ "type": "text", "id": "confirm_prompt", "variant": "body",
"markdown": "I can void ticket **T-4417**. It is an $84.50 tab opened 41 minutes ago." }
],
"pending_actions": [
{
"action_id": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
"plan_id": "1a7f4d02-8e3b-4c15-9f6a-2d7e0b3c5a91",
"tool": "void_ticket",
"command_type": "void_ticket",
"summary": "void ticket — awaiting confirmation.",
"expires_at": "2026-08-20T20:41:07Z"
}
]
}
Nothing has been called at this point. See Actions for the confirm step.
Image turns
{ "prompt": "what is wrong with this invoice?", "imageUrl": "https://…/invoice.png" }
Allowed types and the size ceiling come from AI:Multimodal; the defaults are PNG, JPEG and
WebP up to 4 MB of decoded bytes, and a URL must be https. The bytes are checked against
imageMimeType by their own signature, so a GIF labelled image/png is refused. A turn whose
image breaks any of this is refused before a model is called, on both routes:
{ "error": { "code": "invalid_image", "reason": "too_large", "message": "Images can be at most 4 MB." } }
reason is one of disabled, both_sources, missing_type, unsupported_type, not_base64, too_large, type_mismatch, insecure_url or host_not_allowed. A deployment with
AI:Multimodal:ImageInputEnabled off refuses an image with disabled rather than answering
without it.
Turns that never reach a model
Four paths short-circuit before any spend, and all four return a normal, valid block document:
| Path | Trigger |
|---|---|
| Handoff | The customer asked for a person; the answer is your contact message and handoff is set |
| Greeting | A conversational opener resolved by the deterministic router |
| Out of scope | The policy's blocked-keyword or out-of-scope rule |
| Budget reached | The spend breaker refused the turn |
None of these is an HTTP error. A budget refusal in particular arrives as a text block
explaining that the assistant is unavailable for now — your UI does not need a special case for
it, though telemetry records result_path: "budget_exceeded" so you can see it.
Errors
| Status | Body | Cause |
|---|---|---|
400 | { "error": { "code": "empty_prompt" } } | Missing or whitespace-only prompt |
400 | { "error": { "code": "invalid_image", "reason": "too_large" } } | The image's type, size, encoding or URL is not allowed; see Image turns |
400 | { "error": { "code": "invalid_upstream_token", "reason": "invalid_characters" } } | An X-Tether-Upstream-Token was sent and is malformed; reason is empty, too_long, invalid_characters or repeated |
401 | { "error": { "code": "missing_api_key" } } | No key |
401 | { "error": { "code": "invalid_api_key" } } | Unknown or revoked key |
401 | { "error": { "code": "invalid_customer_token", "reason": "expired" } } | A customer token was sent and did not verify; reason says why. Never treated as anonymous |
403 | { "error": { "code": "session_not_yours", "reason": "other_customer" } } | sessionId names a conversation this guest may not continue; see Continuing a conversation |
A tool that fails does not fail the request. The failure is fed to the model as the tool's result and the turn continues, so the user is told the lookup failed rather than seeing an error page. Full list in Errors.
Related
- Streaming — the same turn as SSE
- The output contract
- Turns and ceilings