Tether
DocsGitHub

Guides

The widget

One script tag and one element. A web component with its own shadow root, so it inherits nothing from your CSS and leaks nothing into it.

<script src="/tether-assistant.js"></script>

<tether-assistant
  mode="floating"
  endpoint="https://api.trytether.ge"
  publishable-key="tpk_…"
  scope-id="9"
  greeting="Hi, what would you like to find today?"></tether-assistant>

Without a developer

The console's last step (console loop O7) does all of this page for a business with a website and no engineer at hand, with nothing to type when it knows the website:

  • The origin comes from the website the visitor started with (scheme, host and port, as a browser sends them). www. and the bare domain are both offered, each a checkbox, both ticked when Tether finds the site served at both.
  • The key is the workspace's one widget key, issued when the step opens and the same every time after; it is publishable, never shown in a URL, and can be replaced under For developers.
  • The script is your Tether API's own copy, so there is no file to host.
  • One snippet carries all of it, plus a floating launcher and the first words in the business's language. Copy it, paste it once before </body>.

The step then says how to paste it on the common ways a small business runs its site, in Georgian and English: any HTML site (before </body>, or once in the shared template), WordPress (as an administrator, a Custom HTML block in the footer; no plugin, and on WordPress.com only on a plan that allows custom code), Wix (Settings → Custom code → Add custom code, All pages, Body - end; a premium plan), Tilda (an HTML code block, T123, in the footer you use on every page), Webflow (Site settings → Custom code → Footer code; a paid site plan) and Shopify (Edit code → layout/theme.liquid, before </body>). There is no plugin to install, for any of them: the snippet is the whole integration. Send to my developer mails the snippet and those steps to one address, with only the publishable key in it; Check my site reads your page and says whether the snippet is there, with this workspace's key, and where the assistant answers. The calls behind them are on the Embed page.

Register your page's origin first

The element calls Tether from your page, so every turn is a cross-origin request the browser preflights. Tether answers that preflight only for origins you have registered, and never with a wildcard: the request carries a key, and a wildcard would let any site that scraped it out of your HTML spend it.

curl -X PUT https://api.trytether.ge/v1/origins \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "origins": ["https://shop.example", "https://www.shop.example"] }'

Scheme, host and optional port — no path, and https unless it is loopback. www.example.com and example.com are different origins to a browser, so register both if you serve from both.

A self-serve workspace registers a public site only once its signup address is confirmed: until then localhost and other local origins work and a public one answers 403 email_unconfirmed with reason public_origin. See what "live" means. With "holdUntilConfirmed": true the public ones wait instead, listed as held, and start working the moment the address is confirmed, with nothing to send again; that is what the console does, so the snippet can be copied and pasted before the email is opened (Embed).

Until this is done the element loads and every turn fails in the console with a CORS error. It is the step that is easiest to skip and hardest to diagnose from the browser's own message, which is why it is here rather than in a troubleshooting section.

Attributes

AttributeRequiredNotes
endpointyesBase URL of your Tether instance
publishable-keyyesThe full tpk_… key, tpk_ and 48 characters, as it was shown when issued. The 12-character prefix a key list shows is not a key and is refused with 401 invalid_api_key. Never a tth_… secret key — that one is a server credential
modenofloating: a launcher button fixed to the bottom-right corner opens the assistant as a panel, and below 640px as a full-screen sheet. It needs no room in your layout, so put the element once in your root layout. Absent: drawn inline, 620px tall unless you set its height, in a container that gives it full width. In a flex row or narrow column an inline element shrinks to a clipped sliver
scope-idnoNarrows every turn to one site, store or client
customer-tokennoThe signed-in visitor, as a customer token your server minted. Also the customerToken property
upstream-tokennoThe signed-in visitor's own session token for your imported API, used when your userToken mode is prefer or require. Also the upstreamToken property. See The visitor's own API token
actionsnoPresent: the visitor may ask for an operation, and the model is offered the tenant's writes (enableWriteActions on each turn). A write it stages waits for a person the business's policy names; this element never approves it. Absent: reads only
greetingnoThe opening line
placeholdernoPlaceholder for the input
attachmentsnoimage: the visitor can attach one photo to a question. See Photos and dictation
dictationnoPresent: a microphone types what the visitor says into the input, where the browser can recognise speech
dictation-langnoThe language dictation listens for, such as ka-GE. Defaults to the element's lang, then the page's <html lang>, then the browser's

The signed-in visitor

To serve a signed-in visitor as themselves, mint a short customer token on your server after your own sign-in and hand it to the element:

<tether-assistant endpoint="…" publishable-key="tpk_…" customer-token="eyJhbGciOi…"></tether-assistant>
document.querySelector('tether-assistant').customerToken = freshToken

It travels in the X-Tether-Customer-Token header on every turn, never in the prompt. A token that is refused, for being expired, forged or another tenant's, is not ignored: the visitor is asked to sign in again, and the element dispatches tether-customer-token-refused with detail.reason so you can mint a fresh one. Without a token the visitor is anonymous.

A conversation continues only for the visitor it was opened for, so the element starts a new one when the token names a different customer, or appears or disappears (a refreshed token for the same customer keeps it), and starts a new one once if Tether answers 403 session_not_yours.

The visitor's own API token

When your API accepts only your users' own session tokens, hand the element the signed-in visitor's token, and Tether calls your imported API with it instead of the credential stored at import:

<tether-assistant endpoint="…" publishable-key="tpk_…" upstream-token="the visitor's session token"></tether-assistant>
document.querySelector('tether-assistant').upstreamToken = refreshedToken

It travels in the X-Tether-Upstream-Token header on every turn and on feedback, never in the prompt. The element reads it again for each turn, so a page that refreshes the session sets the property and nothing is remounted. Tether does not verify it or store it, and uses it only if the business switched it on with userToken (prefer uses it when present, require uses nothing else). A value Tether refuses as malformed (empty, too long, whitespace or non-ASCII) fails the turn, the visitor is asked to sign in again, and the element dispatches tether-upstream-token-refused with detail.reason. An expired token is your API's answer, not Tether's: the model says the sign-in has to be renewed.

It is not a customer token and proves nothing to Tether about who the visitor is; pass customer-token for that. The element never confirms a write, and a write approved in the inbox carries no visitor token, so under require it fails with user_token_required and nothing runs. See Calling your API as the signed-in user.

Photos and dictation

Both are off unless you ask for them, so an existing embed is unchanged.

<tether-assistant endpoint="…" publishable-key="tpk_…" attachments="image" dictation></tether-assistant>

A photo. The attach control opens a picker for one PNG, JPEG or WebP image of at most 4 MB, the same limits the API enforces. A file of another type or over the limit is turned away in the composer before it is read. An accepted one shows as a thumbnail with a remove control, and goes with the visitor's next question as imageBase64 and imageMimeType, never as a URL. If Tether still refuses it, the visitor is told why in your words rather than offered a retry. A turn that carries an image is never offered a write tool.

Dictation. The microphone uses the browser's own speech recognition and appends each phrase it is sure of to the input; the visitor reads it and presses send. What reaches Tether is the text: the element records no audio and Tether receives none. Recognition belongs to the browser, so which languages it understands, how well, and whether it sends the audio to the browser vendor's own service are the browser's matter. Where there is no recognition, as in Firefox, the microphone is not drawn. How well any language is recognised, Georgian included, has not been measured.

The three routes it calls

POST /v1/chat/stream for every turn, POST /v1/chat/feedback when a visitor rates an answer, and GET /v1/chat/brand once when it mounts. Nothing else. A publishable key is scoped by the server to chat, streaming and feedback, and to reading the brand, and the widget does not go looking for the edge of that.

Maps and your own words

A places answer gets a map view only when the page sets map-tiles to a tile URL template with {z}, {x} and {y} from a provider you choose, and map-attribution to the credit line it requires. Without it the answer is a list and no tile is ever requested, because tiles load from the visitor's browser and the provider sees those requests.

Every piece of interface text, such as Call, Directions, Show all, Open until, Attach image or Dictate, can be replaced with the strings attribute, a JSON object, so the widget speaks the page's language. The keys are all, call, directions, map, list, showAll, showLess, places, openUntil, opensAt, opensOn, closed, copy, copied, askAgain, helpful, notHelpful, thanks, whatWentWrong, send, previous, next, zoomIn, zoomOut, attachImage, removeImage, imageType, imageTooLarge, imageRefused, dictate, stopDictation, micBlocked and dictationFailed; {n}, {time}, {day} and {max} are filled in.

Staged writes are shown, never approved here

With the actions attribute set, a turn that stages a write comes back with the preview and the message that it is waiting:

AWAITING APPROVAL
Cancel the 15:00 follow-up with Dr Reyes on Friday
cancel_appointment
Waiting for someone who can approve it.

There is no approve control. The widget runs in a page whose visitor is not authenticated to your tenant, so there is no identity to attribute an approval to. Approval happens in the inbox, or in your own UI.

A handoff is shown as yours

When a turn is handed to a person, the widget shows your contact message once, in its own panel below the answer, with the lang the message was chosen for. It is your words, not the model's, so it is rendered as plain escaped text. The element also raises tether-handoff, with { trigger, message, language } as its detail, so your page can open its own contact channel:

document.querySelector('tether-assistant')
  .addEventListener('tether-handoff', (e) => openLiveChat(e.detail))

tether-answer carries the same object as detail.handoff, or null.

Escaping

Every string that reaches the DOM is escaped. Block content is model output derived from a stranger's question and your data; it is never trusted markup. Markdown support is a deliberate subset — bold, inline code, line breaks — with no links, because a link in model output is an open redirect a visitor will click.

Theme

Brand tokens as CSS custom properties. Override any of them on the host:

tether-assistant {
  --tether-ink: #171717;
  --tether-surface: #F3F0E9;
  --tether-accent: #E9784A;
  --tether-radius: 10px;
  --tether-font: Inter, system-ui, sans-serif;
}

Under those, the widget wears your workspace's brand: the colour and logo Tether read from your site in the website preview, or what the dashboard's Assistant settings set instead (the widget's brand). A token you set on the page wins over it, so a page that already themes the element keeps its own accent.

All nine block types render, including cards and places. Under every answer the visitor can copy it, ask again, and rate it. A type this build does not know is skipped rather than guessed at. An answer drawn from Preview Mode carries its label.