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
| Attribute | Required | Notes |
|---|---|---|
endpoint | yes | Base URL of your Tether instance |
publishable-key | yes | The 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 |
mode | no | floating: 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-id | no | Narrows every turn to one site, store or client |
customer-token | no | The signed-in visitor, as a customer token your server minted. Also the customerToken property |
upstream-token | no | The 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 |
actions | no | Present: 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 |
greeting | no | The opening line |
placeholder | no | Placeholder for the input |
attachments | no | image: the visitor can attach one photo to a question. See Photos and dictation |
dictation | no | Present: a microphone types what the visitor says into the input, where the browser can recognise speech |
dictation-lang | no | The 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.
Related
- Use Tether AI — step five of five
- Rendering blocks · Authentication