Tether
DocsGitHub

API reference

Preview Mode

POST /v1/preview reads a business's own public website so they can see their assistant before doing any integration work. What comes back is explicitly not live, and says so on every answer.

curl -X POST $TETHER_URL/v1/preview \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "siteUrl": "yourshop.com", "feedUrl": "https://yourshop.com/feed.xml" }'
{
  "ingested": true,
  "site": "yourshop.com",
  "pages": 24,
  "feedItems": 180,
  "chunks": 402,
  "pagesRefusedByRobots": 3,
  "stoppedAtPageLimit": false
}

feedUrl is optional. RSS, Atom and Google-style product feeds are all read; a product feed is better preview content than anything crawled out of page markup, because it carries the price, availability and brand as fields rather than as prose. Without one, a feed the first page announces for itself (<link rel="alternate" type="application/rss+xml"> or Atom, on the same site, and not refused by robots.txt) is read instead.

GET /v1/preview reports what is stored and whether the tenant is still preview-only. DELETE /v1/preview removes it.

What an answer looks like

Every answer drawn from crawled content is flagged, on the envelope and on each block:

{
  "meta": { "preview": true, "previewSources": ["https://yourshop.com/delivery"] },
  "blocks": [ { "type": "text", "preview": true, "markdown": "…" } ]
}

meta.previewSources names the crawled pages (or feed links) the answer drew on, at most five, so a surface can say where it came from. The streamed answer (/v1/chat/stream) carries the same label and sources on its completion frame. Render that label. A preview answer presented as if it were live is the one failure this feature exists to prevent.

The model is told, in its own instructions, that it cannot see live stock, orders, prices or account details, that it can take no action, and that it must say so rather than quote a figure from a page as if it were current.

When the label goes away

An answer is a preview answer when it drew on crawled content and called no tool.

Once you register a catalog, a stock question routes to your tool, the answer comes from your system, and it is not flagged — a turn that reached your endpoint is grounded in your live data, and labelling it preview would be wrong in the direction that sounds safer. Your public content stays as background knowledge and stays labelled when it is what answered.

What it will not do

The crawl is bounded and polite, because the address comes from whoever typed it:

  • Your robots.txt is obeyed — the most specific group, longest matching rule
  • The site's own host only, so a link out does not pull in someone else's content
  • Bounded by page count, total bytes and per-page bytes
  • HTTPS and public addresses only, through the same outbound guard as every other call
  • Bounded, before signup, by tighter limits of its own (below)

Before signup: the preview sandbox

POST /v1/preview/sandbox is Preview Mode for somebody with no workspace yet: the onboarding console's "Paste your website". It answers without a credential, like signup, and is closed until an operator sets PreviewSandbox:Enabled (Configuration); closed, it answers 404 sandbox_disabled.

curl -X POST $TETHER_URL/v1/preview/sandbox \
  -H "Content-Type: application/json" \
  -d '{ "siteUrl": "yourshop.com" }'
{
  "sandboxKey": "tsb_…",
  "keyPrefix": "tsb_3f9a…",
  "state": "crawling",
  "site": "yourshop.com",
  "expiresAt": "2026-10-17T10:40:00Z",
  "limits": { "pages": 8, "bytes": 1048576, "seconds": 20, "lifetimeMinutes": 60, "turns": 20 },
  "notice": "A preview of your public site, labelled preview on every answer. …"
}

The site is read in the background, so the answer comes at once. sandboxKey is shown once; it is a sandbox credential (tsb_) and reaches exactly three things:

RequestDoes
GET /v1/preview/sandboxThe sandbox's status: state (crawling, ready, empty, failed or expired), pagesRead while it reads, then chunks, topics (up to four page or feed titles, without the site name a title repeats, for suggested questions), name (that site name: the segment its page titles share, else a home page titled only with it, or null; the console offers it as the workspace's name), items (how many listed items were read, below), brand ({ "color": "#rrggbb" or null, "logo": URL or null }, or null), turnsLeft, expiresAt, error and errorCode (site_challenged when the site answered with a bot challenge)
POST /v1/chat, POST /v1/chat/streamThe preview-only assistant over what was read. Every answer drawn from it is labelled as above. No write mode, no image and no deep analysis, whatever the body asks; the sandbox has no capabilities, so there is nothing to call or stage
POST /v1/preview/sandbox/claimKeep it: { "email": "…", "name": "…" } turns the sandbox into a free workspace with the same preview content, through signup's own checks and answers (202 and a mailed link when Signup:VerifyEmail is on, then POST /v1/signup/verify; otherwise 200 with the one-time apiKey, a tth_ secret key). The sandbox key is revoked when the workspace exists

Anything else answers 403 insufficient_scope with error.reason not_a_sandbox_route, and a workspace's own secret key on the two sandbox routes answers sandbox_credential_required.

What a crawl reads besides prose

  • Brand. The home page's theme-color, else its web app manifest's theme_color, else a CSS custom property named for the brand (--primary, --brand and their kin) in its inline styles or first two stylesheets on the site, else the most used saturated color there. Greys, near-white and near-black never count. The logo is an image named logo, else the touch icon, an SVG or large icon, og:logo, then /favicon.ico; https only unless insecure endpoints are allowed.
  • Listed items. application/ld+json on every page read (products, offers, services, rooms, events, places and their kin), Next.js __NEXT_DATA__, and public JSON endpoints the pages themselves reference: an /api/ path or a .json file on the site's host or a subdomain of it, with no placeholders and nothing under an auth, account, cart or user path. Each is one GET without credentials, its host's robots.txt obeyed, at most MaxDataEndpoints of them and MaxItems items in all, within the crawl's byte and time budget. Arrays of named objects become one line per item, stored as preview content like any page, so answers from them are labelled preview too.
  • A bot challenge. A site whose first page answers with Cloudflare's challenge (cf-mitigated: challenge, or its interstitial) fails at once with errorCode site_challenged. Tether never tries to pass a challenge; the site's owner can allow the preview's user agent.

What bounds it:

  • The crawl. At most MaxPages pages, MaxTotalBytes across the crawl and MaxPageBytes per response, within CrawlSeconds of wall time (what was read by then is kept); robots.txt obeyed, the site's own host only, and a private, loopback or unresolvable address refused with 400 site_refused before anything is created.
  • Who may start one. MaxPerAddressPerDay per source address (stored as a salted hash, as signup's) and MaxGlobalPerHour across everybody, both 429 rate_limited; expired and claimed sandboxes still count. At most MaxConcurrentCrawls read at once on an instance; beyond that, 503 sandbox_busy, never a queue.
  • What it may spend. MaxTurnsPerSandbox answers and SandboxBudgetUsd of model spend per sandbox, and DailyBudgetUsd for every sandbox together in a UTC day. Exhausted, a turn answers 429 sandbox_budget_exhausted with error.reason sandbox_limit or daily_limit, before any model is called. A budget of 0 refuses every turn.
  • How long it lives. LifetimeMinutes, after which turns answer 410 sandbox_expired and the sweep deletes the sandbox, its pages, conversations and key. A claim waiting for its mailed link keeps the sandbox for as long as the link is good.

A claimed sandbox is an ordinary free workspace: everything on this page above applies to it, and everything else in the API is reachable with its secret key.