Tether
DocsGitHub

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"
  }'
FieldTypeDefaultNotes
promptstring—Required. Empty or whitespace returns 400.
sessionIduuidgeneratedEcho the previous response's session_id to continue a thread. A guest may continue only their own; see Continuing a conversation.
includeContextbooleantrueWhether 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.
enableWriteActionsbooleanfalseLets this turn stage writes. Off by default.
imageUrlstring—HTTPS URL to a publicly fetchable image.
imageBase64string—Raw base64 bytes, no data: prefix. Mutually exclusive with imageUrl.
imageMimeTypestring—Required with imageBase64, and must match the bytes.
organizationId, locationIdinteger—Ignored. Scope comes from the credential.

Headers

HeaderNotes
X-Api-KeyRequired. See Authentication.
X-Tether-Scope-IdNarrows the turn to one site, store or client.
X-Tether-User-IdWho is asking, for memory and for the approval trail.
X-Tether-ExplainAdds meta.explain to the response. Secret keys only.
X-Tether-Customer-TokenThe signed-in end customer, as a token your server signed. See Verified customers.
X-Tether-Upstream-TokenThe 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": []
}
FieldNotes
blocksAt least one. Every block has a type and a stable id — see Block types.
metaServer-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.
titleOptional. Present when the model produced one.
session_idEcho it on the next request to continue the conversation.
response_idProvider-side conversation handle. Optional to use; Tether falls back to its own persisted value.
pending_actionsWrites staged by this turn. Empty on a read-only turn, which is most of them.
handoffPresent 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:

PathTrigger
HandoffThe customer asked for a person; the answer is your contact message and handoff is set
GreetingA conversational opener resolved by the deterministic router
Out of scopeThe policy's blocked-keyword or out-of-scope rule
Budget reachedThe 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

StatusBodyCause
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.