TS Embed Guide

Embedding ThoughtSpot, explained.

How each piece works, which choices matter, and what breaks in production. Every claim links to the docs.

Last updated Checked against Guide
0

Start here: the big picture

Five questions every embed answers, two choices that shape the rest, and a path for what you are building.

⚿Who am I?Authentication
⚙What can I do?Roles and privileges
▤What can I see?Content access through sharing
▦Which data?Row and column security
☁Which platform?Your data platform may add its own controls

Answer the pillars in order. Each one builds on the answer before it.

Choice A

How does the user prove who they are?

Compare the sign-in options

then
Choice B

Does each person get their own ThoughtSpot account?

See the identity options

What are you building?

Pick the closest match. The sidebar marks what matters most for it, and matching advice appears inline. Click again to clear.

Your path: customer-facing SaaS

  1. Authentication: the docs name cookieless trusted auth as the recommended trusted type; Embedded SSO is the documented alternative if one IdP allows iframes.
  2. Users, groups & tenants: per-person users are our usual guidance; Orgs, or one Org with tenant rules, trade isolation against operating cost.
  3. Data security: RLS and token variables. Never runtime filters as security.
  4. Hardening: allowlists first; blocking full-app access and trimming actions are options.
  5. Look & feel, then Performance and Environments & releases.
  6. Finish with the go-live checklist.

Your path: internal portal

  1. Authentication: Embedded SSO fits one iframe-friendly SAML/OIDC IdP; trusted auth fits when your app owns the session or you have several IdPs.
  2. How embedding works: pick the component that fits the page.
  3. Filters and drill-through: connect the embed to the rest of your app.
  4. Custom actions: hand off from insight to workflow.
  5. Go-live checklist.

Your path: AI & Spotter

  1. Spotter & AI: choose the embed or API surface and prepare your Models.
  2. Data security: RLS and column security also apply to AI answers.
  3. Authentication: AI surfaces still need a real per-user identity.
  4. What's new: much of this area is Beta or Early access.

Your path: regulated / high-security

  1. Users & tenants: per-user identity so the audit log names a person.
  2. Data security: know which layers are a boundary and which are only UI.
  3. Hardening: work through the checklist end to end.
  4. Authentication: the secret key never reaches the browser; token lifetime is a trade-off to decide with your security team.
  5. Exports: control who can take data out, and how.

Your path: mobile & webview

  1. Authentication: the mobile SDKs support only cookieless trusted auth (docs). For webviews, test cookie behaviour yourself.
  2. Performance: prerendering and full-height layouts.
  3. Look & feel: hide chrome you do not need on small screens.
Go deeper: the ten rules in one screen about 2 min

Rules 2 and 4 are one-sided security rules the docs state. Rules 6 to 8 are SDK mechanics explained in their sections. Rules 1, 3, 5, 9 and 10 are our guidance, with the alternatives in the linked section.

1 · Prefer one ThoughtSpot user per person (our guidance)

A shared account puts one set of data rules, one audit identity and shared content on everyone. Shared accounts are possible; the docs say to discuss them with ThoughtSpot. Options

2 · Mint tokens on your server

The secret key is as sensitive as an admin login. It never reaches a browser, a mobile bundle or a log. How

3 · Cookieless trusted auth, or Embedded SSO if it fits

The docs call cookieless the recommended trusted type; it needs a backend you run. Embedded SSO needs none but has IdP conditions. Compare

4 · Security lives in the data layer

Use RLS, column security and token variables. Runtime filters and hidden buttons are presentation only. The layers

5 · Allowlist hosts; weigh the optional locks

CSP and CORS hosts are required. Blocking non-embed access and trimming actions cost flexibility. Checklist

6 · init once, destroy before re-render

A second render without destroy leaks iframes and listeners. Lifecycle

7 · Runtime filters append

Send the full desired set, and clear a column with an empty list. Dates are numbers, in UTC. Try it

8 · Read the whole clicked row

On a table, the clicked cell is “selected” and the rest of the row is “deselected”. Read both buckets. Drill

9 · Control SDK upgrades, track deprecations

Pinning trades fixes for predictability; floating does the reverse. HostEvent v1 is on a deprecation clock. Releases

10 · Consider promoting content as code

TML through review gives an audit trail; hand-editing production is faster but unreviewed. Pipeline

Go deeper: how to read the labels about 1 min
How to read the labels

GA generally available · Beta works but may change, and is often off by default · Early access must be enabled by ThoughtSpot Support · Deprecated still works, but plan the replacement · Field-observed measured on a live instance, not a documented guarantee · docs links to the source of a claim. Press β in the top bar to highlight every Beta and Early access feature, or / to search.

1

How embedding works

Who talks to whom on every embedded page: your page, the SDK, an iframe, the cluster and your backend.

Host page (your app) <div id="container"> Your app code Visual Embed SDK iframe ThoughtSpot UI runs here ThoughtSpot cluster serves the app, runs queries Your backend token service + REST client ThoughtSpot REST API v2 tokens, metadata, search init() getAuthToken() server-to-server: request token render() load UI + token EmbedEvent HostEvent your REST calls and responses never from the browser with secrets
  1. init once
    Call init() once with thoughtSpotHost and authType, before any embed. docs
  2. SDK asks for a token
    The SDK calls your getAuthToken(), which calls your backend. See Authentication.
  3. Backend gets the token
    Your server authenticates the user, then asks the ThoughtSpot REST API for a token for them.
  4. render creates the iframe
    new LiveboardEmbed(el, cfg).render() inserts an iframe. docs
  5. Iframe loads from the cluster
    The frame loads the app from your cluster and presents the token. Queries run in the cluster under that user’s permissions.
  6. Events flow both ways
    You subscribe to EmbedEvents with .on() and send commands with embed.trigger(HostEvent...). See SDK lifecycle.
  7. REST goes through your backend
    Privileged REST calls come from your server. Never ship an admin credential to the page.
The browser holds a UI iframe and a short-lived token. Anything privileged happens on your backend.

The browser holds a UI iframe and a short-lived token. Anything privileged happens on your backend.

Which embed component?

Which embed component?

Multi-tenant SaaS: LiveboardEmbed and SearchEmbed give a narrower surface and more layout control. AppEmbed gives ThoughtSpot navigation at the cost of a relatively fixed layout (docs). Your backend decides which tenant a token belongs to.

AI and Spotter: use SpotterEmbed for ThoughtSpot’s chat UI, SpotterAgentEmbed when you own the chat chrome. For SpotterEmbed the docs set the token expiration to at least 10 minutes; we found no equivalent statement for SpotterAgentEmbed, so test long conversations. See Authentication.

You have a saved Answer that was never pinned to a Liveboard and want to show it alone. Which is correct?

A saved Answer cannot be embedded as an individual visualization. SearchEmbed accepts answerId, and hideSearchBar removes the search bar so only the result shows.

Go deeper: component comparison table about 2 min
ComponentShowsKey inputChoose it when
LiveboardEmbedA Liveboard, or one visualizationliveboardId (+ vizId)Curated dashboards, KPI tiles
SearchEmbedSearch bar, data panel, results; or a saved AnswerdataSource or answerId (+ hideSearchBar)Self-service exploration; standalone saved Answer
SearchBarEmbedSearch bar onlydataSourceSearch in your own UI, results shown by you
SpotterEmbedSpotter chat experienceworksheetId (data source)Natural-language analytics with ThoughtSpot’s UI
SpotterAgentEmbedNothing visible (prompt panel only)prompts in, structured answers outChatbots and custom AI front ends
AppEmbedFull ThoughtSpot app or pagespageId, navigation flagsWhole-product surface; fixed layout acceptable

Sources for this table: docs docs docs docs.

Go deeper: deprecated classes you may still see about 1 min

Deprecated classes you may still see

ConversationEmbed Deprecated

Replaced by SpotterEmbed and SpotterEmbedViewConfig. The old names keep working “until further notice”; migrate anyway. docs

BodylessConversation Deprecated

Replaced by SpotterAgentEmbed and SpotterAgentEmbedViewConfig, also in SDK 1.39.0. docs

SageEmbed Deprecated

Sage search (the legacy natural language search) was deprecated from 10.11.0.cl and removed from the product in December 2025 (the live deprecation page now says removed); SageEmbed is deprecated with it. Use Spotter. docs Treat any remaining SageEmbed code as dead and migrate it. docs

Go deeper: pitfalls, rules and plain iframes about 3 min
A saved Answer is not a vizId

A visualization is an Answer pinned to a Liveboard. A standalone saved Answer has no Liveboard, so you cannot target it with vizId. Use SearchEmbed({ answerId, hideSearchBar: true }). docs (hideSearchBar: SDK 1.21.0 | TS 9.2.0.cl docs)

  • Prefer one Liveboard over many single-viz embeds (the docs' performance advice).
  • Keep token minting and admin REST calls on the server (the secret key must not reach a browser).
  • In React, the components (@thoughtspot/visual-embed-sdk/react) wrap the same embeds; the plain classes also work. docs
  • Do not mix AppEmbed with SearchEmbed or LiveboardEmbed on one page.
  • Do not put long-lived credentials or token-minting endpoints open to the browser.
  • Do not start new work on the deprecated Conversation or Bodyless classes: they are deprecated but still work.
Iframe without the SDK?

You can embed a plain iframe if your host disallows custom JavaScript. You then lose events, callback custom actions, auto-login, React components, and the granular flags and menu-action controls (runtime filters can still be passed as URL parameters, and the comparison table says Search is not available). Fit: hosts that disallow custom JavaScript. If you can run the SDK, you keep these features; weigh that against the host constraint. docs

Docs conflict: iframe URL parameters

The comparison table says no menu-action controls and no Search without the SDK, but the embed-without-sdk page documents hideAction, disableAction and visibleAction URL parameters and a Search page URL format. Verify on your cluster before ruling either out. docs

The SDK model also applies in webviews, where third-party cookies are often blocked, so plan for cookieless token auth.

0

How ThoughtSpot connects to your login

Your users already sign in with your own IdP (Okta, Auth0, Cognito, Entra) or a custom login. There are two ways to connect that login to ThoughtSpot.

Way A · AuthType.EmbeddedSSO

ThoughtSpot signs the user in

ThoughtSpot redirects the user to your SAML or OIDC IdP inside the iframe, reusing the SAML/OIDC setup already on ThoughtSpot. docs GA

  • Who does what: ThoughtSpot talks to the IdP. Your backend does nothing.
  • Fits when: one SAML or OIDC IdP that allows iframe redirects, and SSO already configured on ThoughtSpot. docs
or
Way B · TrustedAuthTokenCookieless

Your app signs the user in, then vouches

Your app logs the user in its own way. Your backend then asks ThoughtSpot for a token for that user, using a secret key. docs GA

  • Who does what: your app owns the login. Your backend mints the token.
  • Fits when: several IdPs without federation, users not in an IdP, or an IdP that cannot be iframed. docs
Way A: ThoughtSpot signs the user in Your app Your IdP ThoughtSpot 1 user signs in 2 embed loads the iframe 3 redirects in iframe 4 same user, signed in Your backend is not involved. Way B: your app signs in, then vouches Your app Your backend ThoughtSpot 1 user signs in 2 SDK asks for a token 3 request with secret key 4 token 5 token to the SDK 6 iframe uses the token ThoughtSpot never talks to your IdP.
Same users either way. In A, ThoughtSpot sends the user to your IdP. In B, only your app talks to your IdP, and your backend vouches for the user.

Way A lets ThoughtSpot reuse your IdP. Way B keeps ThoughtSpot out of your login.

Way A has three conditions (the docs advise against it otherwise)

①One IdPThe docs do not recommend EmbeddedSSO with multiple IdPs. docs
②IdP allows iframesMost IdPs block SAML inside an iframe by default. Okta has an "Enable iframe embedding" option. docs
③SSO set up on ThoughtSpotWithout SAML/OIDC configured there, EmbeddedSSO behaves like None. docs

Which way fits you?

Choosing how users sign in start here Q0 Do users sign in through an IdP? SAML or OIDC: Okta, Entra, Auth0, Cognito Q1 Are all users in the same IdP? or does each tenant bring its own Q1a Is there IdP federation? one IdP fronts the others Q2 Can the IdP login load inside an iframe? check X-Frame-Options and frame-ancestors Set up SAML or OIDC on ThoughtSpot a setup step, not a decision Best fit: trusted auth AuthType.TrustedAuthTokenCookieless needs a backend you run Best fit: Embedded SSO AuthType.EmbeddedSSO no token service, but IdP conditions apply YES NO custom login, magic links, passkeys YES NO YES NO NO YES
Four checks and one setup step. Any "no" is a case where the docs do not recommend Embedded SSO, which leaves trusted auth (needs your own backend) or, for SAML or OIDC IdPs that cannot be iframed, the redirect types. The docs list multiple IdPs without federation, users outside your IdP, and an IdP that cannot allow iframe redirects as reasons to choose trusted authentication docs.
Prefer to click through it? Answer the four questions about 1 min
Your login + ThoughtSpot
Go deeper: ten dimensions, side by side about 2 min
DimensionEmbeddedSSOTrusted auth (cookieless)
Where authentication happensInside the ThoughtSpot iframeIn your app, before ThoughtSpot loads
Number of IdPsOneAny, because your backend is the converging point
Backend neededNoYes, a token service
Per-session data rulesNot directly; use group claims, pre-persisted values or an ACL tableFirst-class: pass values on each mint
Offboarding latencyBound by the IdP and ThoughtSpot session policy (the docs do not give a figure)Stop minting; the existing token works until it expires, unless you revoke it
App-side audit trailLimitedEvery session starts with a mint you can log
Third-party cookiesThe docs list Embedded SSO among the options that avoid the third-party-cookie problem docs. The IdP login still runs inside the iframe, so test it in Safari and in private windows before launch (our guidance).None by design
Front-end codeA few linesA few lines plus a backend endpoint
Moving partsFewer. IdP outage: the docs are silent; expect sign-in at the IdP to fail (our inference)More (token service, entitlement lookup). Token service outage: new tokens cannot be minted (our inference)
Multi-tenant futureThe docs advise against it with multiple IdPs; per-Org IdPs and Per Org Subdomain (GA since 26.5.0.cl; enabled via Support) exist for OrgsAny number of IdPs, because your backend is the converging point

This table is our synthesis of the documented selection guidance, not a table the docs publish; cells marked as inference are ours. Per-Org IdPs and a Per Org Subdomain exist for clusters that use Orgs. docs docs

Partitioned cookies

For cookie-based sessions or None, enable partitioned cookies in Develop > Security Settings. The docs state Safari blocks all third-party cookies and does not support partitioned cookies, so this does not fix Safari. docs

Go deeper: enterprise questions, with options and trade-offs about 3 min

Facts are from the docs; the trade-offs are our reading. Where the docs are silent we say so.

QuestionWhat the docs sayOptions and trade-offs
MFAAuthType.Basic is documented to fail when MFA is enabled on ThoughtSpot, and so do REST calls that use basic authentication (username and password) to session/login and the token endpoints (MFA for local auth is Beta from 10.6.0.cl; see hardening). Token calls with secret_key are the documented path. docs Nothing else about MFA is stated for embeds.Trusted auth: your app authenticates, so MFA is whatever your login enforces (inference: ThoughtSpot never sees the login). Embedded SSO: the IdP enforces it in the iframe (inference). Ask ThoughtSpot Support what applies to your instance.
IdP or login outageNot documented.Both ways depend on your login. Embedded SSO adds the IdP-in-iframe hop; trusted auth adds your token service. Existing trusted tokens work until expiry, so a longer lifetime rides out short outages but widens the leak window.
Regulated orgs: A or BThe docs choose by IdP conditions, not by regulation. docsEmbedded SSO keeps the user credential flow at the IdP and has no secret key to protect, but gives you less app-side audit (our reading). Trusted auth adds a high-value secret key and a service to harden, and gives a mint log you control (our reading). Choose by who your auditors trust to enforce login.
Several IdPs or tenants with their own IdPDocs recommend trusted auth for multiple IdPs without federation, and advise against Embedded SSO. Per Org Subdomain lets Orgs with different IdPs be identified. docsTrusted auth: one integration, your routing logic. Per-Org IdPs: ThoughtSpot-side per-tenant SSO, but it needs Orgs and Support to enable it (Per Org Subdomain is GA since 26.5.0.cl). See Who each person becomes.
User lifecycle on exitSee the deprovisioning table in identity.With Embedded SSO the docs describe user creation at first login, not removal, so confirm with ThoughtSpot whether an IdP removal deactivates the ThoughtSpot user; plan an explicit deactivation either way.
0

Trusted auth, step by step

Phase A is the login your app already has. Phase B is the new part: your backend vouches for that user to ThoughtSpot.

PHASE A · YOUR EXISTING LOGIN · NOTHING THOUGHTSPOT-SPECIFIC PHASE B · YOUR BACKEND VOUCHES TO THOUGHTSPOT Browser (SDK) Your app & IdP Your backend ThoughtSpot 1 open the app 2 user signs in at your IdP 3 app session cookie set 4 getAuthToken() → GET /ts-token (cookie) 5 verify session, pick the ThoughtSpot username 6 POST auth/token/custom + secret_key username, JIT fields, RLS variable values 7 token returned 8 fresh token to the SDK 9 iframe and requests carry Authorization: Bearer token
  1. User opens your app
    Phase A. A protected route, no session yet. Nothing here involves ThoughtSpot.
  2. User signs in at your IdP
    Your app sends the user to Okta, Auth0, Cognito, Entra, or your own login form. You can use SAML, OIDC or a custom login; ThoughtSpot never sees it. The docs list user sessions, JWTs and LDAP/AD as places the token service finds the user. docs
  3. Your app sets its own session
    This cookie or JWT belongs to your app. Its only job later is to prove to your backend who the user is. ThoughtSpot never receives it.
  4. The SDK asks for a token
    Phase B begins. init() calls your getAuthToken callback, which calls your endpoint with the app session attached. docs
  5. Your backend decides who
    It verifies the session and derives the username itself, never from the request body. It also looks up, server-side, the user's groups and the values your RLS rules need, and whether a first-time user should be created. docs
  6. Backend calls ThoughtSpot with the secret key
    POST /api/rest/2.0/auth/token/custom (TS 10.4.0.cl+), which the docs recommend for ABAC via RLS. docs One call can create the user (JIT) and hand over the values RLS reads. No user password is needed. The secret key lets the caller request a token for any username, so it stays on the server. docs
  7. ThoughtSpot returns a token
    The default validity is 300 seconds. docs
  8. Backend returns a fresh token
    Return a new token on every call. A duplicate triggers DUPLICATE_TOKEN_ERR. docs
  9. The SDK uses it as a bearer token
    In cookieless mode the token is used on every request and no browser session is created. With autoLogin: true the SDK fetches a new token before expiry. docs
Phase A is your login and stays as it is. Phase B adds one backend call to ThoughtSpot.

The session cookie proves the user to your backend. The secret key proves your backend to ThoughtSpot. Two credentials, two trust relationships.

What goes in the token request

One auth/token/custom call answers three questions about the user. Only username and persist_option are required. docs

⚿Who is this?username, secret_key, org_identifier, validity_time_in_sec (default 300)
+Create them on first visit?JIT: auto_create, display_name, email, groups
⛨Which rows may they see?ABAC: variable_values + persist_option, optionally scoped with objects

Just-in-time users: the conditions

  • Send auto_create: true with display_name, email and groups ([{ "identifier": "…" }]) for users who may not exist yet. docs
  • Use the secret_key: JIT provisioning requires it. docs
  • Check privileges: adding a user during auto-creation requires ADMINISTRATION, or under RBAC CONTROL_TRUSTED_AUTH plus edit access to the data source. docs
  • Expect JIT to update an existing user: with auto_create: true their display name, email, Org and group entitlements are not changed. docs
  • Expect it to create formula variables. It creates the user (and any new groups, with default options), never the variables. docs
  • Forget the default: auto_create is true on auth/token/custom (10.5.0.cl+) but false on auth/token/full. docs

ABAC with RLS: how the values reach a query

1Create the variableA formula variable, once, via template/variables/create
→
2Reference it in RLSThe table's RLS rule reads the variable
→
3Send values in the tokenvariable_values per user, at mint time
→
4Every query is filteredModels, Liveboards, Answers and Spotter
Watch for these

The variable must already exist: name refers to an existing formula variable. docs

With variable_values, persist_option must be APPEND or REPLACE. RESET and NONE work only without them. docs

Values are written to the user's profile when the token is created, not when it is used, and later sessions keep using them. A token you mint and throw away still changes the user. docs

Version gates differ between pages (the REST field says 10.14.0.cl, the ABAC guide says 10.15.0.cl), and RLS, not runtime filters, is the security boundary. Details in Data security.

The token endpoint does six jobs (our breakdown)

The path is yours (/ts-token or anything else). ThoughtSpot does not provide this endpoint, and every security property of the integration passes through it.

1AuthorizeReject requests with no valid app session.
→
2IdentifyTake the user from your session, never the request.
→
3ResolveLook up groups or RLS values server-side.
4Build payloadUsername, org, JIT fields, RLS values.
→
5Call ThoughtSpotWith secret_key from a secrets manager.
→
6Return and logSend the token, record the mint.
Go deeper: the endpoint in code about 3 min
import express from 'express';

const app = express();
const TS_HOST = process.env.TS_HOST;            // https://your-instance.thoughtspot.cloud
const SECRET_KEY = process.env.TS_SECRET_KEY;   // from your secrets manager, never sent to the browser

// 1. requireAppSession rejects unauthenticated requests (401).
app.get('/ts-token', requireAppSession, async (req, res) => {
  // 2. Identity comes from YOUR verified session, not from req.query or req.body.
  const username = req.user.email;

  const tenant = await lookupTenant(req.user);   // your data: org, groups, RLS values

  const r = await fetch(`${TS_HOST}/api/rest/2.0/auth/token/custom`, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'X-Requested-By': 'ThoughtSpot',
    },
    body: JSON.stringify({
      username,
      secret_key: SECRET_KEY,
      validity_time_in_sec: 300,           // the default; a security trade-off, see the lifetime notes
      org_identifier: tenant.org,          // only if Orgs are enabled
      persist_option: 'REPLACE',           // required; APPEND or REPLACE when sending variable_values
      // JIT: create the user on first visit (existing users are not updated)
      auto_create: true,
      display_name: req.user.name,
      email: req.user.email,
      groups: [{ identifier: tenant.group }],
      // ABAC: values for the formula variable your RLS rule reads
      variable_values: [{ name: 'allowed_regions', values: tenant.regions }],
    }),
  });

  if (!r.ok) {
    console.error('token mint failed', r.status); // 6. audit log, no secrets
    return res.status(502).json({ error: 'token_unavailable' });
  }
  const { token } = await r.json();
  res.set('Cache-Control', 'no-store');
  res.json({ token });
});

The fields follow the auth/token/custom reference. docs allowed_regions and lookupTenant are placeholders for your own names.

import { init, AuthType } from '@thoughtspot/visual-embed-sdk';

init({
  thoughtSpotHost: 'https://your-instance.thoughtspot.cloud',
  authType: AuthType.TrustedAuthTokenCookieless,
  autoLogin: true,
  getAuthToken: () =>
    fetch('/ts-token', { credentials: 'include' })
      .then((r) => r.json())
      .then((d) => d.token),
});

No RLS variables and no JIT? auth/token/full with username, secret_key and validity_time_in_sec is enough, and its auto_create defaults to false. docs

Go deeper: four bugs to look for in review about 2 min

These are review guidance from experience. The linked docs cover the underlying facts.

1. Identity from the request body

The browser sends { tenant, region } and the backend stamps it in. A user edits the body and gets someone else's scope.

Fix: read identity from your verified session, then look up entitlements server-side. docs

2. Secret key in git or the browser

The key allows a token request for any username with no further authentication. A leaked key lets anyone sign in as anyone.

Fix: keep it in a secrets manager, never in the browser. docs

3. Endpoint without auth middleware

The route is reachable with no session, so anyone can mint tokens. Easy to miss behind a load balancer.

Fix: require your app session on the route, and test that an unauthenticated call returns 401.

4. Token lifetime is a security setting

A cookieless token is a bearer credential: whoever holds it acts as that user until it expires, and activity never extends it. docs A long lifetime widens the window for a leaked token and for offboarding.

Trade-off: shorter means a smaller leak and offboarding window but more mint requests; longer (the docs mention 20 to 30 minutes or more for cookieless) means fewer requests and a wider window. autoLogin: true fetches a new token before expiry. docs Revoking at sign-out or offboarding narrows the window. docs

Plan for rotation

Disabling trusted authentication invalidates the previous secret key, and a new key is generated when it is enabled again. docs The previous key is invalidated as soon as trusted authentication is disabled docs, and the docs describe no overlap period, so mints fail until your service holds the new key. Options (our guidance): rotate in a planned window with a rehearsed runbook; or add retry with backoff in the token service. Rotate at once if the key may have leaked.

Go deeper: the four questions teams always ask about 3 min
If we use trusted auth, what is the point of OIDC? Are they substitutes?

No. They sit at different layers. Your IdP (OIDC or SAML) signs the human into your app. Trusted auth lets your backend get a token from ThoughtSpot for the user your app already knows. The docs' implementation steps for trusted auth are: enable it, build the token service, and add the SDK. Configuring SAML or OIDC on ThoughtSpot is not one of them. docs

What is in the session cookie, and does ThoughtSpot see it?

It is your app's own cookie, and it never reaches ThoughtSpot. Your token service reads the logged-in user from your session, a JWT or LDAP/AD. docs ThoughtSpot only checks the secret key. If the endpoint skips the session check, anyone who knows a username could request that user's token.

How are users created in ThoughtSpot? Is there a separate provisioning call?

Not necessarily. With auto_create: true the token request creates the user if missing. You must send display_name and email; send group_identifiers only if you want JIT group assignment. The docs warn that this creates users and any new groups with default options, and that the users and groups endpoints give full control. docs The token service may also call other REST APIs, using a service account with administrator privileges. docs

We have two IdPs, Okta for customers and Entra for staff. How does that work?

Both sign users into your app, and both end at the same backend. The docs recommend trusted authentication for multiple IdPs without federation, because ThoughtSpot sees one kind of token request from one trusted service. docs Routing by subdomain or email domain is your app's job. The cost is that one backend, one secret key and your routing logic become critical. If you use Orgs, per-Org IdPs with Per Org Subdomain (GA since 26.5.0.cl, enabled via Support) are the ThoughtSpot-side alternative. docs

Go deeper: running the token service in production about 3 min

Documented facts, then options with trade-offs. The options are our guidance.

ConcernWhat the docs sayOptions and trade-offs
AvailabilityThe SDK calls your getAuthToken to get tokens, and with autoLogin asks again before expiry. docs No availability figure is documented.Several instances behind a load balancer cost more to run but remove a single point of failure. A longer token lifetime rides out short outages but widens the leak window. Decide with your uptime and security targets.
Rate limitsFrom 26.2.0.cl, a cluster-level default of 100 requests per second per client IP across all public APIs, plus a burst of 10, then HTTP 429. Limits can be adjusted through Support. Announced: ThoughtSpot will also enforce a separate 50 requests per second per Org limit on Orgs-enabled instances from 26.11.0.cl. docsLengthening the token lifetime lowers mint volume. If your service egresses through one NAT address, all mints share one client IP (our inference). Add retry with backoff for 429, or ask Support to adjust.
CachingReturn a fresh token on every call; a duplicate triggers DUPLICATE_TOKEN_ERR. docsDo not cache and re-serve a minted token. Caching the entitlement lookup (groups, RLS values) cuts load on your own data store, at the price of stale access for the cache lifetime.
Key rotationDisable and re-enable trusted authentication; the old key stops working at once. Per-Org keys exist from 9.2.0.cl through Support. docsPer-Org keys limit what one leaked key exposes but multiply keys to manage (our inference). Rotation always needs a coordinated cutover.
AuditNot documented as a ThoughtSpot feature for the mint call.Log every mint on your side (user, org, time, outcome), never the key or token. Our guidance: cheap, and it is the only record you control.
0

Auth methods and sessions

Which AuthType values exist, how a session starts and ends, and what breaks in browsers.

Choosing between EmbeddedSSO and trusted auth is covered in the auth choice, and the token flow and token service in the trusted-auth flow. This section is the menu and the session lifecycle. docs docs

The seven auth types

Production

AuthType.TrustedAuthTokenCookielessDocs: recommended

Cookieless trusted auth GA

Your backend mints a token and the SDK sends it as a bearer token. No browser session and no third-party cookie. SDK 1.22.0+ · TS 9.3.0.cl+

AuthType.EmbeddedSSOWhen it fits

Embedded SSO GA

ThoughtSpot signs the user in against your SAML or OIDC IdP inside the iframe. No token service. SDK 1.15.0+ · TS 8.8.0.cl+

Conditional

AuthType.TrustedAuthTokenConditional

Cookie-based trusted auth

The token is used once to create a browser session. Works with v1 or v2 tokens.

AuthType.SAMLRedirect · AuthType.OIDCRedirectConditional

Redirect to an IdP that cannot be embedded

The SDK redirects the host page, or opens a popup with inPopup: true.

Dev only

AuthType.None

Pass-through login

The user logs in to ThoughtSpot in the iframe. The docs say it is not recommended for production. docs

AuthType.Basic

Username and password in init()

Do not use in production. It fails if MFA is enabled. docs

The docs recommend cookieless as the trusted type and advise against Basic in production. EmbeddedSSO is the documented alternative when its conditions hold. docs

Go deeper: conditional types in detail about 2 min
  • EmbeddedSSO behaves like None if SSO is not configured on the cluster.
  • TrustedAuthToken: the docs list it for an IdP that does not support Embedded SSO, users who do not exist in your IdP, or multiple IdPs without federation. It works in most situations only with ThoughtSpot on the same top-level domain as the app (custom domain); cookieless avoids the same-domain constraint.
  • SAMLRedirect and OIDCRedirect need SAML redirect domains allowlisted in Security Settings.
  • None needs partitioned cookies enabled in dev and test.
  • To avoid reloading the host in redirect flows, use popup mode: inPopup: true (SDK 1.18.0) with authTriggerContainer and authTriggerText, or useEventForSAMLPopup and authEE.emit(AuthEvent.TRIGGER_SSO_POPUP). The noRedirect option is deprecated; use inPopup. Deprecated docs
Host allowlists come first

Before any auth type works, add your host domain to the CSP visual embed hosts and the CORS allowlist. A "Refused to frame" error means one is missing. Only users with a valid embed license can add visual embed hosts. docs docs

The token is the credential for every request. REST v2 tokens only, and no username in init() because the token carries it. docs

import { init, AuthType, AuthStatus } from '@thoughtspot/visual-embed-sdk';

const authEE = init({
  thoughtSpotHost: 'https://your-instance.thoughtspot.cloud',
  authType: AuthType.TrustedAuthTokenCookieless,
  autoLogin: true,
  // Must return a FRESH token on every call.
  getAuthToken: () =>
    fetch('/ts-token', { credentials: 'include' })
      .then((r) => r.json())
      .then((d) => d.token),
});

The token is exchanged once for a browser session cookie. v1 or v2 tokens. It works in most situations only if ThoughtSpot sits on the same top-level domain as the app, which means a custom domain. docs

init({
  thoughtSpotHost: 'https://analytics.yourapp.com', // same top-level domain as the app
  authType: AuthType.TrustedAuthToken,
  autoLogin: true,
  username: 'user@example.com', // cookie-based init takes the username (cookieless does not)
  getAuthToken: () => fetch('/ts-token', { credentials: 'include' }).then((r) => r.json()).then((d) => d.token),
});
Go deeper: your own REST v2 calls under cookieless auth about 2 min
Way A

SDK's token via tokenizedFetch

Browser calls reuse the bearer token the SDK already holds. SDK 1.28.0+ docs

Fits when: the page calls REST v2 as the signed-in user (listing Liveboards, filter values). Costs: no second mint. Gives up: it only works in the browser, after the SDK has its token.

or
Way B

Your own token from the backend

Your page or server mints a token and sends Authorization: Bearer itself. docs

Fits when: a server job, or a call that must not depend on the embed. Costs: one more mint per token, which we expect to count against the REST rate limit (our inference). Gives up: a second token to store and expire.

  • With cookieless auth the SDK uses the token as a bearer token and establishes no session, so a plain fetch() to /api/rest/2.0/... carries no credential. The REST page says to obtain a bearer token or set session cookies before calling an endpoint. docs That a bare fetch() then fails is our inference from those two statements.
  • tokenizedFetch takes the same arguments as the Web fetch and adds the token to the request. The docs say it works for any authorization method. docs
  • The docs' own example stores the token in a global variable from inside getAuthToken for other REST calls. docs A token never extends, and autoLogin replaces it before expiry, so a saved copy goes stale (our inference). tokenizedFetch avoids keeping one.
  • Returning a reused token from getAuthToken triggers DUPLICATE_TOKEN_ERR, so do not point getAuthToken at a cached copy to share it. docs
import { tokenizedFetch } from '@thoughtspot/visual-embed-sdk';

const r = await tokenizedFetch(`${TS_HOST}/api/rest/2.0/auth/session/user`, {
  method: 'GET',
  headers: { Accept: 'application/json' },
});
if (!r.ok) throw new Error(`REST ${r.status}`);
Field-observed, and not a docs statement

Field-observed A playground pinned to SDK 1.49.0 sent its REST discovery calls with no bearer under TrustedAuthTokenCookieless, and its Connect step failed until the calls carried the token. The docs do not describe this failure; the cause matches the two statements above. Host-page fetch() calls also need your page origin in the CORS allowlist. docs

Third-party cookies and Safari

Safari

Blocks third-party cookies by default, even in standard mode.

Chrome Incognito

Blocks them too. Embed content is blocked when cookies are.

The fix

Cookieless trusted auth has no cookie dependency. docs

Partitioned cookies do not fix Safari. The documented routes are cookieless auth, or ThoughtSpot on a custom domain under your own.

Go deeper: cookie rules about 1 min
  • Option 1, cookieless trusted auth: no cookie dependency, needs a token service.
  • Option 2, a custom domain under your app's top-level domain: the cookie is first-party, but you run DNS and certificates for it.
  • Option 3, for non-Safari browsers only: with cookie-based or None, enable partitioned cookies in Develop > Security Settings or via cluster_preferences.enable_partitioned_cookies. docs
  • Do not rely on partitioned cookies to fix Safari. The docs state Safari blocks all third-party cookies and does not support partitioned cookies.
  • Do not detect cookie access with detectCookieAccessSlow unless needed. The docs call it slower and say it should be avoided. docs

If cookies are blocked in cookie-based mode, the SDK emits EmbedEvent.NoCookieAccess and you should switch to cookieless. Because the token is used once, the docs suggest a validity under a minute. docs

Token lifetime and refresh

1MintDefault validity is 300 seconds. Activity never extends a v2 token.
→
2RefreshautoLogin: true requests a new token before expiry.
→
3RevokeOn sign-out, call POST /api/rest/2.0/auth/token/revoke.

A cookieless token is a bearer credential until it expires, and autoLogin refreshes it. How long it lives is a trade-off, below. docs

Lifetime is a security trade-off

The default is 300 seconds. For cookieless the docs say you may want "20 or 30 minutes, or more", and for cookie-based under a minute, because the token is used once to open a session. docs The longer cookieless suggestion saves token requests. It is not a security recommendation.

Shorter: smaller leak and offboarding window, more mints (watch the 100 requests per second per client IP default, see the token-service panel). Longer: fewer mints, rides out token-service blips, wider window.

A longer token means a stolen token works longer and a removed user keeps access until it expires (or until you revoke it). docs Our guidance, if you want a starting point: begin at the default, lengthen only if refreshes cause visible problems, keep the token in memory rather than in storage or URLs, and size your token service for the mint volume. Regulated teams may reasonably choose shorter than the default; that is a policy call.

One documented floor: for SpotterEmbed, set the token to at least 10 minutes, because Spotter workflows can run longer, and keep refreshAuthTokenOnNearExpiry on. docs

Go deeper: refresh options, v1 tokens and endpoints about 3 min
  • autoLogin: true makes the SDK request a new token before the current one or the session expires, so the user never sees a signed-out embed. docs
  • refreshAuthTokenOnNearExpiry is a view config option (documented on AppViewConfig, SearchViewConfig and the conversation view configs) and defaults to true. New SDK 1.45.2+ · TS 26.3.0.cl+ docs
  • Revoking on sign-out matters more the longer your cookieless tokens live. The revoke endpoint (9.0.0.cl+) takes a user_identifier or a token; revoking another user's token needs sufficient privileges per the REST reference. docs
V1 tokens behave differently

Legacy v1 tokens (cookie-based only) stay valid until another is generated, generating a new secret key invalidates all earlier tokens, and repeated login attempts with an expired token can lock the user out. Cookieless does not support v1 tokens. docs

Which token endpoint (reference)

EndpointFits whenNotes
POST /api/rest/2.0/auth/token/fullYou need a full-access token and no per-user data variables.9.0.0.cl+. If both secret_key and password are sent, the REST reference says secret_key wins, but the authentication guide says password wins; send only one. validity_time_in_sec defaults to 300. auto_create defaults to false. Takes org_id (integer). docs
POST /api/rest/2.0/auth/token/objectLock a viewer to one object (a Liveboard or Answer).9.0.0.cl+. Scoped to one object_id, intersected with the user's group access. The docs describe the token as read-only access to that object. docs
POST /api/rest/2.0/auth/token/custom GAYou pass RLS formula variable values (ABAC) per user.10.4.0.cl+. persist_option is required (APPEND, NONE, RESET from 10.4.0.cl; REPLACE from 10.5.0.cl). docs Takes org_identifier (string), not org_id. auto_create defaults to true from 10.5.0.cl. variable_values arrived in 10.14.0.cl. docs

Multi-Org tokens Beta A token request by a cluster administrator can carry a scope object instead of org_id, and each API call then picks its Org with the X-Org-Selector header. full, object and custom support it, and custom supports SPECIFIC_ORGS only. It is disabled by default and needs Support to enable. docs Our guidance: treat it as an admin or back-office tool, not a replacement for one token per tenant user.

Copy-paste hazards between endpoints

Full and object take org_id (integer); custom takes org_identifier (ID or name). auto_create defaults to false on full and object, true on custom. persist_option NONE or RESET cannot be combined with variable_values. Passing filters or parameters inside the token is being phased out: user_parameters on full and object is deprecated from 10.4.0.cl, filter_rules on custom is no longer available for new configurations as of 26.3.0.cl (existing implementations keep working), and parameter_values is supported but will be deprecated (the docs conflict: the ABAC page already lists it as deprecated; see Legacy ABAC). The docs steer new work to RLS variables (variable_values). Deprecated docs

When a session ends

init() returns an event emitter. Wire it up before you render so you can tell "token exchange done" from "embed confirmed" and from every failure. docs

1SDK_SUCCESSToken exchange done. Safe to render.
→
2SUCCESSThe iframe confirmed the session.
→
3FAILURE or LOGOUTExpiry, idle timeout, blocked cookies or sign-out.

With autoLogin: true the SDK refreshes tokens, but failure and logout are yours to handle: the SDK will not guess your sign-in page.

Multi-tenant SaaS: cookieless trusted auth is the documented-recommended trusted type. On full-app embeds, the server-side non-embed access setting (the SDK flag blockNonEmbedFullAppAccess is being phased out) stops users opening ThoughtSpot outside your app (cost, our inference: no direct ThoughtSpot access for support or admins on that route). Revoking at sign-out adds a REST call per sign-out.

Mobile and webviews: the mobile SDKs support only TrustedAuthTokenCookieless. docs The docs do not detail cookie behavior in in-app webviews, so treat them like Safari.

Go deeper: events table, wiring and init options about 3 min

EmbedEvent.AuthFailure, EmbedEvent.IdleSessionTimeout and EmbedEvent.AuthLogout appear in the docs error table but are not listed in the EmbedEvent enum reference, so confirm them in your SDK version; the AuthStatus signals on the init() emitter are the ones the enum reference documents. docs

What happenedSignalWhat to do
Session or token expiredEmbedEvent.AuthExpireSet autoLogin: true so the SDK refreshes before expiry.
Idle timeoutEmbedEvent.IdleSessionTimeout, AuthStatus.FAILURE with IDLE_SESSION_TIMEOUTRoute to your own sign-in or re-init.
Auth failed in the iframeEmbedEvent.AuthFailureCheck the token service response; show your own message.
User logged outEmbedEvent.AuthLogout, AuthStatus.LOGOUTClear app state. Call logout() and re-init with autoLogin: true to sign back in.
Cookies blockedEmbedEvent.NoCookieAccess, FAILURE with NO_COOKIE_ACCESSSwitch to cookieless.
Duplicate or invalid tokenDUPLICATE_TOKEN_ERR, INVALID_TOKEN_ERRORAlways return a fresh token from getAuthToken.
import { AuthStatus, AuthFailureType } from '@thoughtspot/visual-embed-sdk';

authEE.on(AuthStatus.SDK_SUCCESS, () => renderEmbed());          // token exchange done
authEE.on(AuthStatus.SUCCESS, (s) => console.log(s.userGUID));   // iframe confirmed
authEE.on(AuthStatus.FAILURE, (type) => {
  // type is an AuthFailureType: EXPIRY | IDLE_SESSION_TIMEOUT | NO_COOKIE_ACCESS
  //   | OTHER | SDK | UNAUTHENTICATED_FAILURE
  if (type === AuthFailureType.NO_COOKIE_ACCESS) fallBackToCookieless();
});
authEE.on(AuthStatus.LOGOUT, () => goToAppSignIn());

Other relevant options on init(): disableLoginRedirect (SDK 1.9.3, pair with token auth plus autoLogin), loginFailedMessage, disableLoginFailurePage (SDK 1.32.3) and, for full-app embeds, blockNonEmbedFullAppAccess (SDK 1.22.0), which blocks users from opening ThoughtSpot pages outside the iframe. docs

Regulated: token lifetime is a policy trade-off (see above). Restricting CSP visual embed hosts and CORS to exact production origins has a one-sided risk benefit. docs

Your app is used in Safari and you enable partitioned cookies for a cookie-based setup. Does that fix blocked embeds?

Partitioned cookies help in some other browsers; for Safari the fix is a design that needs no cookies. docs

0

Who each person becomes in ThoughtSpot

A token says "this is user X". ThoughtSpot still needs a user X, in the right groups, in the right tenant.

Shared service account

Everyone is one user

  • One set of data rules for all
  • Every action logs as the same user
  • Favorites and saved content are shared
or
Per-user accounts

Each person is their own user

  • Rules, audit and content follow the person
  • Schedules and alerts run as a real user
  • Our usual guidance; costs one user per person

Per-person users keep audit, data rules and schedules tied to a person (our reasoning). Shared accounts are possible, but the docs say to discuss them with ThoughtSpot because of access-control and RLS implications. docs

Go deeper: why per-user, and the license question about 2 min

The docs note that many ThoughtSpot features are tied to individual user accounts with a valid email address, and that JIT provisioning and the user REST APIs make creating them easy. Shared customer-level or role-level accounts are possible, but the docs say to discuss them with your ThoughtSpot team because of access-control and RLS implications. The per-user preference below is our reasoning from that, not a docs rule. docs

Audit

With one shared user, every action in ThoughtSpot logs as that user. You cannot answer "who changed or exported this". This is our reasoning; the docs do not discuss audit logging here.

Row-level security

RLS and ABAC rules attach to the user. A shared user can have only one set of rules, and per-person rules persisted on it can overwrite each other between concurrent sessions (our inference: persisted values are stored on the user object). docs

Personalization and content

Favorites, saved Answers, private content and sharing are per user. A shared account makes them shared too.

Scheduled and async work

Schedules and alerts run as a user. Persisted values for a user are used for scheduled reports, so they are only meaningful if the user maps to one person. docs

Three-question test

Should different people see different data? Does any work run when they are not looking (schedules, alerts)? Will anyone ever ask "who did that"? A yes to any points toward per-user accounts. A shared account fits best for public, read-only, identical views; the docs describe a dedicated public user with a tight ACL for that case. docs

"But per-user accounts may cost more licenses"

How users count toward your license is a contract matter the docs do not detail. They name two embed licenses: "Embed Add-on" for internal apps used by your employees, and "ThoughtSpot Embedded" for customer-facing or public apps. docs Options and what each gives up (our analysis):

MappingFits whenGives up
One user per personDifferent people see different data, schedules run, or someone must answer "who did that"Possibly more licensed users; more lifecycle work
One user per customer or roleEveryone in a tenant should see the same data, and shared content is acceptablePer-person audit, favorites, private content, and per-person persisted RLS values
One public user, tight ACLIdentical public, read-only views (documented by the docs)Everything personal

Raise the pricing model with ThoughtSpot before choosing; collapsing identity to save cost moves the cost to audit and data security.

How should I map my users to ThoughtSpot?

How should I map my users to ThoughtSpot?

Four ways users and tenants get created

Just-in-time (JIT)

Set auto_create: true on a token request and the user is created when the token is minted. JIT users have no password and are SSO-only.

Explicit REST

users/create for one user, users/import for bulk sync. Full control, and no coupling to token minting.

Groups

Keep one purpose per group: content access, role or privilege, or data security. This taxonomy is our recommendation; the docs do not prescribe it. docs

Orgs

An isolated tenant container in one instance, with its own users, groups and data objects. Needs a license and cannot be turned off once enabled. docs

For many small tenants, one Org plus a tenant column and RLS is a lighter-to-operate option (our view); isolation then rests entirely on your rules. docs

Multi-tenant SaaS (our guidance): namespace usernames per tenant on a shared Org so two tenants cannot collide on user@example.com, and keep group names tenant-scoped.

Regulated: per-person accounts let audit trails name people. Explicit provisioning gives reviewed changes; JIT is less work but changes happen at login without a review step (our analysis).

Go deeper: JIT provisioning about 3 min

The effect happens at token creation, not at use. You need display_name and email, plus org_id when Orgs are enabled, and group_identifiers only if you want JIT group assignment. The token request needs secret_key, because the username and password path cannot JIT. Creating users requires administration privilege (or CONTROL_TRUSTED_AUTH under RBAC). docs

Behaviortoken/full and token/objecttoken/custom
auto_create defaultfalsetrue (10.5.0.cl+)
Existing user, auto_create: trueUpdates display name, email and groups on every mintDoes not update the existing user
Org parameterorg_id (integer)org_identifier (ID or name)
group_identifiersList of names/IDsList of objects {identifier}

Sources: the JIT page and the REST v2 authentication guide. docs docs

Group semantics on JIT

With token/full and /object, omit group_identifiers to leave groups unchanged, and send [] to remove the user from all groups. Because they overwrite on every mint, a stale group list in your token service silently changes access. On /custom, groups apply at creation only, so a role change in your system does not reach an existing user. Reconcile groups through the user or group REST APIs when roles change. docs

Typos can create groups

The docs note that auto_create creates the user and any new groups with default options. docs Field reports describe such a group being created with no privileges. Field-observed The docs do not state the privilege detail. Our guidance: validate group names against an allowlist in your token service and pre-create groups explicitly, which costs you a provisioning step.

Go deeper: explicit provisioning through REST about 2 min
  • POST /api/rest/2.0/users/create (9.0.0.cl+): requires name, display_name, email. Optional account_type (LOCAL_USER, LDAP_USER, SAML_USER, OIDC_USER, REMOTE_USER), org_identifiers, group_identifiers, account_status, visibility, preferred_locale. docs
  • POST /api/rest/2.0/users/import (9.0.0.cl+): bulk upsert and sync. dry_run defaults to true, so nothing is written until you set it false. delete_unspecified_users defaults to false and is destructive when true (it never deletes tsadmin, guest, system or su). docs

Explicit provisioning gives you full control of every option and does not couple user changes to token minting. A common pattern is explicit sync for groups and roles (a scheduled import) plus auto_create only as a safety net for first login. The token service needs secret_key and a separate admin service account for any other REST calls; do not confuse the two. docs

Go deeper: Orgs in detail about 3 min

There is one non-deletable Primary Org with ID 0. Orgs require an Analytics Enterprise or ThoughtSpot Embedded license, are enabled by default on new Enterprise cloud instances, and cannot be turned off once enabled. docs docs

  • Scope a token to an Org with org_id (full, object) or org_identifier (custom). If omitted with a secret_key, the token is for the Org that key belongs to. docs
  • Per-Org secret keys exist from 9.2.0.cl, enabled by contacting Support. You can also enable trusted auth programmatically with POST /api/rest/2.0/auth/configure (26.6.0.cl or later). docs docs
  • Create Orgs with POST /api/rest/2.0/orgs/create (9.0.0.cl+; cluster admin, or ORG_ADMINISTRATION under RBAC). docs
  • Per-Org SSO: with a per-Org IdP, only claims referencing that IdP's Org are honored; cross-Org claims are dropped and logged as security audit events, and existing manually assigned Org memberships are not removed by a per-Org IdP login. docs Separately, OIDC Org mapping needs Support to enable; while it is on, group mapping does not work, and a mapped login assigns the user only to the Orgs in the assertion (removing the others). docs
  • Per Org Subdomain (<org>.<cluster>.thoughtspot.cloud) is generally available (it was Early Access before 26.5.0.cl) and is enabled by contacting Support. Org names must be DNS-friendly and new Orgs need a new request. GA docs
  • Per-Org custom embed URL and cross-Org links (overrideOrgId, SDK 1.35.0) are generally available since 26.5.0.cl (Early Access from 10.5.0.cl before that) and enabled via Support; the docs note overrideOrgId may not work with trusted auth if tokenAuthPerOrg is enabled. GA docs
Orgs are a one-way door

You cannot disable Orgs once enabled, and the Org of a token silently follows the parameter you send. Mixing up org_id and org_identifier between endpoints scopes tokens to the wrong Org, or fails to scope them at all. Test per-tenant isolation with two real tenants before launch.

Orgs versus one Org, in trade-offs

OptionFits whenCostsGives up
Org per tenantTenants need their own users, groups, models and adminsOrgs license; per-Org setup (tokens, optional per-Org keys through Support)Reversibility: Orgs cannot be turned off; announced: ThoughtSpot will enforce a 50 RPS per-Org REST limit on Orgs-enabled instances from 26.11.0.cl, alongside the cluster limit (docs)
One Org, tenant column, RLS or ABACMany small tenants, shared models acceptableRule design and two-tenant testingStructural isolation; a rule bug is a data leak
Separate instancesContract or regulation demands physical separation (our judgement)Highest operating and cost burden (our inference)Single pane of administration

Facts are from the Orgs and rate-limit pages. docs docs Cost and effort cells are our judgement.

Single Org with data-level separation

One Org with a tenant_id column and RLS or ABAC on top. All tenants share one set of data objects. It is lighter to operate for many small tenants, but isolation depends entirely on correct rules and tokens. docs Runtime filters and Liveboard filters are not a security boundary; use RLS or ABAC for anything where a wrong answer is a data leak. docs

Go deeper: leaving, role changes and deprovisioning about 3 min

Documented behaviour first; the sequencing is our guidance. We found no SCIM provisioning described in the embedding docs.

ActionWhat the docs sayTrade-off
Stop minting tokens (trusted auth)An issued v2 token stays valid until it expires; activity never extends it. docsFree, immediate for new sessions; the existing token lives out its lifetime.
Revoke the tokenPOST /api/rest/2.0/auth/token/revoke (9.0.0.cl+) accepts user_identifier or token. docsEnds access sooner; needs privileges and an admin-side call from your offboarding flow.
Deactivate the userPOST /api/rest/2.0/users/deactivate (9.7.0.cl+, needs ADMINISTRATION, or USER_ADMINISTRATION under RBAC) returns an activation URL valid 14 days. The docs say deactivating is preferable to deleting because references are kept. docsKeeps ownership and audit references; the account still exists.
Delete the userPossible, but the docs prefer deactivation. docsCleaner directory, but references to the user are lost.
Scheduled users/import syncdelete_unspecified_users defaults to false and is destructive when true.One reconciliation job covers drift; a wrong source list can delete users. dry_run defaults to true, so nothing is written until you set it false; review the dry-run result first.
New users get email by default

The docs say ThoughtSpot sends email and enables onboarding workflows when a new user first logs in, even when embedded, and that this can be changed system-wide in the onboarding settings. Check this before bulk JIT or imports, so customers are not surprised. docs The same page says to avoid creating local user profiles in large production environments to cut admin overhead.

Go deeper: RBAC about 2 min

Role-based access control replaces group-level privileges with roles, which are collections of privileges such as manage users, groups, roles, Orgs, authentication or application. It is off by default, you enable it by contacting Support, and once enabled it cannot be disabled. Existing group privileges migrate to role privileges. docs

  • Privilege names change under RBAC: creating users needs USER_ADMINISTRATION, token auto_create needs CONTROL_TRUSTED_AUTH, managing formula variables for custom tokens needs CAN_MANAGE_VARIABLES, and orgs/create needs ORG_ADMINISTRATION. docs docs
  • Role endpoints: roles/search, roles/create, roles/{role_identifier}/update, roles/{role_identifier}/delete, from 9.5.0.cl. docs
  • Your token service and admin service account need the right RBAC privileges, or JIT and provisioning calls start failing after enablement.
Status: treat as Beta

The REST v2 changelog (9.5.0.cl) tags the role APIs as Beta ("roles APIs work only if RBAC [beta] feature is enabled"), and the group API page calls RBAC a Beta feature. The RBAC page itself shows no beta tag, and the REST reference for the role endpoints carries no beta tag. Beta Because enablement is irreversible, confirm the current status with ThoughtSpot before you turn it on. docs docs

Your token service uses auto_create: true on auth/token/full and sends group_identifiers built from a cached role list. A user's role changes in your app. What happens in ThoughtSpot?

On full and object, auto_create: true on an existing user updates display name, email and groups. token/custom does not update existing users, so there a role change never arrives through the token. docs

0

Data security: what stops a user seeing a row

Which controls are a real boundary, and how your backend feeds per-user entitlements into them.

Boundary or presentation only?

Only RLS, CLS, the ABAC variables that feed RLS, and warehouse-side per-user rules are a security boundary. If the user can edit it in the URL, the SDK config or a REST call, it is context, not security.

Click a layer for detail. docs

Your embed passes runtimeFilters: [{columnName:"Tenant", operator:"EQ", values:["acme"]}] so each customer only sees their rows. Is the tenant isolated?

Runtime filters are controlled by whoever controls the embed config or URL, and the docs say plainly they are not a security feature. Put the tenant column in an RLS rule (group-based, or a ts_var() variable from the token). docs

How ABAC feeds RLS

Your backend fills a typed formula variable for one user at token time, and an RLS rule on a Table reads it with ts_var(). ThoughtSpot expands the rule into a WHERE clause at query time. docs

Browser + SDK Your backend ThoughtSpot Warehouse getAuthToken() Identify user from sessionresolve entitlements POST /auth/token/customvariable_values + persist_option store values, mint JWTJWT (via backend) Bearer JWT: open Liveboard / ask WHERE region IN ('NA','EMEA') only allowed rows
  1. The SDK asks your backend for a token
    With AuthType.TrustedAuthTokenCookieless the SDK calls your getAuthToken. Return a fresh token each call. See the authentication section.
  2. Your backend decides what this user may see
    Take the username from your own authenticated session. Resolve entitlements (regions, departments, tenants) server-side. Never accept the username or any variable value from the browser.
  3. Mint with variable_values
    POST /api/rest/2.0/auth/token/custom with username, persist_option (APPEND or REPLACE with variables), secret_key and variable_values: [{name, values[]}]. Optionally scope with objects: [{type: "LOGICAL_TABLE", identifier}]. docs
  4. ThoughtSpot stores the values and returns a JWT
    Side effects happen when the token is created, not when it is used, so a token you discard still changes the user's stored values. docs
  5. The embed runs with the bearer token
    Every request carries the token. The user can open content shared with them; the data they get is decided in the next step.
  6. The RLS rule becomes a WHERE clause
    A rule like region = ts_var(region_var) expands per user. One value becomes =, several become IN (...). The user cannot alter it.
  7. The warehouse returns only permitted rows
    Everything downstream (charts, Spotter answers, exports, schedules for that user) sees the same filtered data.
Your backend sets the values, an RLS rule reads them, and ThoughtSpot appends the WHERE clause. The browser is never in the loop.

Your backend sets the values, an RLS rule reads them, and the browser is never in the loop.

Four ways to carry entitlements: you choose

Group-based RLS

Rule reads ts_groups or ts_username. Fits stable, coarse attributes like tenant. You manage group membership.

ABAC variables

Your backend sends variable_values at each token mint. Fits dynamic or multi-valued attributes. You run a token service.

ACL-table join

An entitlements table in your warehouse is joined in the rule. Fits entitlements that already live there. You run the pipeline. docs

Warehouse OAuth

Rules live in the warehouse, per user. Fits internal users with warehouse accounts. Sign-in is less seamless. docs

The docs do not rank these four. The trade-off table in the first panel below compares them on enforcement point, freshness, effort and audit.

Admins bypass RLS

The docs verify entitlements with a user who lacks the "Can administer and bypass RLS" privilege. Keeping embed end users out of admin roles follows from that, and the risk is one-sided. docs docs

Go deeper: trade-offs between the four options about 4 min

The docs describe each mechanism but do not rank them. Cells marked docs restate a documented behaviour. Cells marked our reading are our judgement, and where the docs are silent the cell says so. docs docs

OptionEnforced whereFreshnessOperational effortPerformanceAuditWhat you give up
Group-based RLSThoughtSpot, as SQL on the Table (docs)As current as group membership. Group names and usernames cannot be changed after creation (docs)Keep groups in step with your data values. Our reading: grows with the number of distinct valuesDocs give no figure. Test on your warehouseMembership via users and groups APIs and audit events (docs)Multi-valued or per-login attributes fit poorly
ABAC variablesThoughtSpot, RLS rule reads ts_var() (docs)As of the last token mint, because values are persisted then (docs)Token service must call /auth/token/custom and send the full set each time. Needs Trusted AuthenticationDocs give no figure. Test on your warehousevariables/search with METADATA_AND_VALUES shows stored values (docs)Not session-scoped. Needs a ThoughtSpot release with formula variables
ACL-table joinThoughtSpot, rule joins an entitlements table (docs)Read at query time from the table, so it is as fresh as your pipeline (our reading)You maintain the table and its updates. Usernames must match exactly (docs)An extra join. The docs say each warehouse optimizes it in its own way and give no figureThe table is your own record of who may see what, so it is auditable in your warehouse (our reading)Entitlement logic lives outside ThoughtSpot, with its own owners
Warehouse OAuthThe warehouse, per user (docs)Whatever the warehouse says at query time (our reading)Needs an individual warehouse account per user and an OAuth provider. Not every connector supports it (docs)Docs give no figureWarehouse-side query history can attribute queries to a person. The docs do not describe this, so confirm on your platformSeamless sign-in. Docs say it tends to suit internal users, not embedded customers

What tips the choice. Who already holds warehouse accounts. Whether entitlements change per login or per week. Whether you can run a token service. Whether your auditors want enforcement in the warehouse or in ThoughtSpot. Combinations are common: a tenant group rule plus an ABAC variable for a sub-tenant attribute is described in the docs as a layering, not an either-or. docs

The rule uses the system variable ts_groups (or ts_username). Example: tenant IN ts_groups, or region = ts_username. ThoughtSpot compares against group name, which must match your database values exactly, and group names and usernames cannot be changed after creation. docs

  • Docs: create separate RLS groups used only to match data values, and keep other group names from accidentally matching a data value. docs
  • Works with any auth method, including SSO where the IdP assigns groups. Field-observed

Formula variables set from variable_values in the token request, as below. Your backend decides the values on every login.

  • Several dimensions at once (region and product) can be carried in one token.
  • Needs a token service (Trusted Authentication) that can call /auth/token/custom.
  • One option if admins need a bypass inside the rule: 'admins' in ts_groups OR region = ts_var(region_var). The cost is that the bypass now lives in rule text that someone must review. Field-observed

An entitlements table in your warehouse is joined to the fact table in an ACL-table rule, matched on ts_username (or the user's group membership). Your app must keep that table up to date in the warehouse itself. docs docs

  • Needs no token-time logic, so it also works where you cannot mint tokens with values, such as AuthType.EmbeddedSSO flows.
  • The cost is a pipeline to maintain and a join on every query.
If this is trueIt leans towardMain alternative, and what would change the answer
One tenant key per customer, entitlements rarely changeGroup-based (ts_groups)ABAC variables, if you later need several attributes per user
Entitlements are multi-valued or change per login, you run a token serviceABAC variablesACL table, if the entitlements already live in the warehouse and you prefer one source of truth there
Entitlements already live in a warehouse tableACL-table joinABAC variables, if you would rather push the values from your app than maintain a pipeline
You use EmbeddedSSO and need attributes beyond group claimsGroup claims or ACL tableVariables can be pre-persisted with an admin token mint, but expect sync lag
Per-session entitlementsDedicated one-time users with persisted rules (docs)Variables are persisted, not session-scoped, so they do not fit directly
Users already have individual warehouse accounts and rulesWarehouse OAuthThoughtSpot-side RLS, if sign-in friction or account provisioning is the blocker
Go deeper: enterprise controls, audit and isolation testing about 4 min
ConcernWhat the docs offerTrade-off or open question
Column-level securityThe owner shares only specific columns of a Table with a user or group. Docs: create separate CLS groups, as with RLS groups. docs docsSharing is per Table object and so needs governance as columns are added. Warehouse-side masking is a different layer, and the docs we checked do not describe it. The OAuth option moves it to the warehouse.
Who may change security rulesThe "Can administer and bypass RLS" privilege (BYPASSRLS) allows creating, editing or deleting RLS rules and enabling or disabling Bypass RLS on a Model. CAN_MANAGE_VARIABLES manages formula variables, and CAN_CREATE_OR_EDIT_CONNECTIONS manages connections. docsOur guidance: treat these like production database admin rights, keep the holder list short, and review it on a schedule. How short is your risk call.
Audit of entitlementsSecurity audit logs cover logins, users, Orgs, roles, groups, connections, data objects, RLS, Answers and Liveboards. Fetch or push them as described in Running it in production. docsThe docs do not say how long logs are kept, so confirm retention for your compliance window. Stored variable values can be listed with variables/search, which is a point-in-time snapshot, not a history.
Org per customer or shared Org with RLSThe docs tie it to warehouse design: one multi-tenant warehouse can use a single prod environment with RLS and sharing, while per-customer databases or schemas use a prod Org per customer. The level of separation is "totally up to" you. Orgs isolate tenant workspaces. docs docsOrgs give stronger workspace separation and more objects to deploy and operate. A shared Org has less to run and puts the weight on RLS correctness. The choice is yours.
Network and data residencyNot covered by the developer docs we searched (IP allowlisting, private connectivity, region choice).Unverified here. Ask ThoughtSpot Support or your account team, and do not assume.

Testing tenant isolation

Docs: log in as a user without the bypass privilege, start the session with the ABAC token, open a Liveboard, Saved Answer or new search, inspect the generated SQL, confirm only permitted rows show, confirm no data shows when no values are set, and repeat for different assignments. docs Our guidance, not in the docs: add negative tests where tenant A asks for tenant B's values through filters, Spotter and downloads, and run them again after every model or rule change.

Include Spotter in the test matrix: the docs state it runs through the instance, which enforces RLS and CLS. Exports and schedules are our addition to the matrix, because the docs we checked do not test them for you.

Go deeper: the ABAC lifecycle, rules and version about 4 min

The lifecycle in three calls

// POST /api/rest/2.0/template/variables/create
{ "type": "FORMULA_VARIABLE", "name": "region_var", "is_sensitive": false, "data_type": "VARCHAR" }

Types are VARCHAR, INT32, INT64, DOUBLE, DATE and DATE_TIME. BOOLEAN and TIME are not supported. Creating variables needs a cluster or Org admin, or the CAN_MANAGE_VARIABLES privilege. docs The 10.9.0.cl REST changelog tagged the template/variables endpoints Beta; the ABAC guide does not restate that, so check current status for your release. docs

region = ts_var(region_var)

No quotes around the variable name. Add rules for each variable the table needs. docs

// POST /api/rest/2.0/auth/token/custom
{ "username": "UserA",
  "secret_key": "<from your secret store>",
  "validity_time_in_sec": 300,
  "persist_option": "REPLACE",
  "variable_values": [
    { "name": "region_var", "values": ["NA", "EMEA"] }
  ],
  "objects": [ { "type": "LOGICAL_TABLE", "identifier": "<model-guid>" } ] }

Rules that matter

TopicBehaviorSource
persist_optionWith variable_values only APPEND and REPLACE are accepted. NONE and RESET are not supported. REPLACE makes the token request the source of truth, so every mint must carry the full variable set. APPEND keeps earlier values and adds yours, which is less to send and easier to leave stale. Choose on whether you want the token request or the stored state to be authoritative.docs
Deny by defaultEvery variable referenced in an RLS rule is required. A variable with no value, or TS_WILDCARD_NONE, returns no data (the query fails with "No values are assigned to some or all Formula Variables").docs
WildcardsTS_WILDCARD_ALL allows every value, TS_WILDCARD_NONE makes the rule false. Our guidance: reserve ALL for a deliberate, reviewed case such as internal staff.docs
Object scopingIf you omit objects, the values apply to every rule using that variable across the Org for that user. Only LOGICAL_TABLE is supported.docs
No per-session ABACValues are persisted, not session-only. For per-session rules, create dedicated one-time users with persisted rules and cookieless auth. That also secures scheduled Liveboard attachments.docs
IndexingToken values are not applied to indexing. Disable indexing on sensitive columns, or put RLS on them.docs
Groups on /token/customGroups are assigned only when the user is created. /token/full and /token/object replace groups on every request.docs
Docs disagree on the version

The What's New page announces formula variables in 10.15.0.cl and the guide says they are available from 10.15.0.cl, with a note to contact ThoughtSpot Support if the feature is not enabled. The REST reference tags the variable_values field "Version: 10.14.0.cl or later". Treat 10.15.0.cl as the safe minimum and confirm enablement on your cluster. GA docs docs

Go deeper: legacy filter_rules / parameter_values and the cutover about 4 min

Deprecated See the paragraph below for the conflicting docs and the plan to move off.

Before formula variables, tokens carried filter_rules and parameter_values that bound to column and parameter names. The docs conflict on its status. The ABAC page marks the approach Deprecated in 26.3.0.cl, "will be removed in a future release", with existing implementations continuing "until further notice". docs The REST changelog is narrower: for 26.3.0.cl filter_rules is no longer available for new configurations, while parameter_values is supported and "will be deprecated in an upcoming version"; What's new also does not call parameter_values deprecated yet. docs user_parameters on /auth/token/full and /object has been deprecated since 10.4.0.cl. The safe position: no new work on any of them, and plan for all to go. No removal date is stated. Use ABAC via RLS for all new work. docs An older Beta flow through /auth/token/full and /auth/token/object is also deprecated, with its own migration guide. docs

Legacy gotchas to know while you still have it

The two legacy fields are linked: sending one without the other deletes the omitted one from the user profile. docs Filter column names must match exactly or the filter does not apply, and end users must not have edit access to the Worksheets or Models involved. docs The method needs Worksheets or Models, not individual Tables, as sources. docs

Cutover plan

  1. Audit. List every place your backend sends filter_rules or parameter_values, and which columns and Models they touch. Follow the official migration guide. docs
  2. Create variables for each attribute (variables/create, then reference them in rules).
  3. Add RLS rules with ts_var() on the Tables. Legacy filters and RLS both apply, so the result is the intersection and can only be stricter. Field-observed
  4. Send both for a coexistence window: legacy fields plus variable_values. Verify with a non-admin user and POST /api/rest/2.0/template/variables/search using response_content: METADATA_AND_VALUES, and inspect the generated SQL. docs
  5. Remove the legacy keys from the mint request. Avoid sending an empty values: [] to clear things unless you intend a wipe. Standardize on one mint endpoint. Field-observed
Legacy persistence side effects (reference)
  • Legacy persist_option values were NONE, APPEND, RESET (10.4.0.cl+) and REPLACE (10.5.0.cl+). With cookie-based auth and NONE, token attributes are ignored. docs
  • The Beta-era flow had a known gap where schedules created in ABAC-token sessions did not follow security rules. docs
Go deeper: Spotter, do and don't, scenarios about 3 min

Spotter, the Spotter MCP server and the Spotter REST APIs all run through the ThoughtSpot instance, which "enforces data security with RLS and CLS rules". Your existing models and governance carry over. docs docs

  • runtimeFilters on SpotterEmbed (SDK 1.41.0 / TS 10.13.0.cl) persist across a conversation, but remain context, not security. docs
  • CAN_USE_SPOTTER gates access to Spotter. CAN_MANAGE_SPOTTER does not override data access permissions. docs
  • The docs returned for this guide do not describe how Spotter's language-model prompts treat sample values beyond the statement above. Our guidance: keep sensitive columns covered by RLS or CLS, and weigh disabling indexing on them.

Firm rules (doc-stated, or the risk is one-sided):

  • Enforce every data boundary with RLS, CLS, ABAC variables or warehouse-side rules.
  • Reference every variable in a rule so that missing values deny access.
  • Derive values server-side from your own session.
  • Test as a user without the bypass privilege and check the generated SQL.
  • Use runtime filters, parameters or Liveboard filters for isolation.
  • Accept variable values, usernames or groups from the browser.
  • Start new work on filter_rules / parameter_values.
  • Give embed users edit access to models carrying security logic (the legacy ABAC pages list this as a known limitation).

Judgement calls, covered above: which pattern, APPEND or REPLACE, and Org per customer or shared Org.

Multi-tenant SaaS: The docs frame the choice by warehouse design. A multi-tenant warehouse with a tenant key on every row can use one production Org with RLS, while per-customer databases or schemas point to an Org per customer. Sub-tenant attributes can be layered with ABAC variables. docs

Regulated: Our guidance, to weigh against your auditors' view: enforce deny by default, consider disabling indexing on sensitive columns, and review the audit log and stored variable values on a schedule. For schedules, the docs say session-only values are not supported and point to dedicated one-time users with persisted rules.

0

Hardening the embed

Four controls: keep the embed on your domains, keep the secret off the browser, and decide how data may leave. Some items are mandatory, others depend on your context.

1AllowlistsCSP and CORS list only your exact origins
→
2Full app accessDecide whether to block non-embed access
→
3Action surfaceHidden buttons are UX; privileges enforce
→
4Token hygienesecret_key stays server-side

Restrict origins and keep the secret key on the server without exception. For non-embed access, downloads and token lifetime, you choose, and the panels below show the trade-offs.

Hardening checklist

  • Mandatory: doc-stated or one-sided risk
  • Context-dependent: decide, then record why
Go deeper: CSP, CORS, SAML and non-embed access about 4 min

These are set under Develop > Customizations > Security settings, or with POST /api/rest/2.0/system/security-settings/configure. They need an embedding license. Some are cluster-level only and some can be overridden per Org. docs The REST reference tags the endpoint TS 26.2.0.cl+ and requires the ADMINISTRATION or DEVELOPER privilege (cluster-level SAML and script-src settings need ADMINISTRATION). docs

SettingWhat it doesLevelWatch for
CSP visual embed hostsSets frame-ancestors: which sites may frame ThoughtSpot. REST: cluster_preferences.csp_settings.visual_embed_hosts[].Cluster only (all Orgs)Wildcards like *.domain.com work for CSP. Add http:// for localhost or non-HTTPS, and explicit ports. A missing host blocks framing (browsers typically report "Refused to frame"). Field-observed
CORS allowlistWhich origins may call ThoughtSpot APIs from the browser.Cluster, overridable per OrgWildcard prefixes are not supported for CORS. CSP and CORS lists together are limited to 4096 characters. No query strings, no semicolons.
SAML redirect domainsAllowed redirect targets for SAMLRedirect. Enter domains without protocol, comma-separated. REST: cluster_preferences.saml_redirect_urls[].Cluster onlyNeeded only if you use SAMLRedirect. Leave empty otherwise.
Block non-embed full app accessStops users opening the full ThoughtSpot UI outside your embed. REST: non_embed_access.block_full_app_access, with groups_identifiers_with_access for exceptions.Cluster, overridable per OrgFits when no user should reach the full ThoughtSpot UI. Gives up standalone access for support or analyst users unless you add a group exception. Off (false) by default in the UI. Does not restrict users with admin or developer privilege. Exceptions need "selective user access" enabled, and groups_identifiers_with_access can be set only when block_full_app_access is true. The selective-access page also says there is no API support for it, which conflicts with the REST reference, so verify on your cluster.

Sources: CSP domain formats per-Org SAML non-embed selective access. Mobile and hybrid apps can use custom schemes such as capacitor://localhost in the lists, but a shared scheme does not identify your app: every app on the device using that framework presents the same origin, so it is not an access-control boundary and you must enforce access with cookieless trusted auth. docs

// POST /api/rest/2.0/system/security-settings/configure
{ "cluster_preferences": {
    "csp_settings": { "visual_embed_hosts": ["app.example.com"] },
    "non_embed_access": { "block_full_app_access": false } },
  "org_preferences": [ { "org_identifier": "<org-name-or-id>",
      "non_embed_access": {
      "block_full_app_access": true,
      "groups_identifiers_with_access": ["group1"] } } ] }

On the SDK side, blockNonEmbedFullAppAccess exists in init() (SDK 1.22.0+ · TS 9.3.0.cl+). The reference lists a default of true but also marks the option Optional, so set it explicitly if you rely on it. docs From 10.6.0.cl the docs treat the Security Settings toggle and selective user access as the control; they say selective access overrides the SDK flag and that "support for the SDK blockNonEmbedFullAppAccess will end soon". Options: (a) the server-side setting, which the docs treat as the control and which is not tied to an SDK version, at the cost of a per-Org setting to review; (b) the SDK flag, which needs no cluster change but whose support the docs say will end soon. Our reading: do not build new work only on the SDK flag. docs

Block non-embed access does not restrict admins

Users with admin or developer privilege are not restricted, and admins bypass RLS by default, so embed end users should not hold those roles. docs

Go deeper: the action surface about 4 min

Embeds expose menu actions (download, share, schedule, edit and so on). You can shape them with visibleActions (allowlist; an empty array removes all), hiddenActions (denylist), and disabledActions (greyed out but visible) with disabledActionReason as the hover tooltip. Setting both visibleActions and hiddenActions leads to an error in the embedded content. docs Action IDs come from the Action enum, and which ones apply depends on the embed type, so try them in the Visual Embed Playground before shipping. Custom actions get an automatic ID you can use in the same arrays. docs docs

const embed = new LiveboardEmbed('#tsEmbed', {
  liveboardId: '<guid>',
  visibleActions: [Action.Save, Action.Pin, Action.DownloadAsPdf], // never combine with hiddenActions
  disabledActions: [Action.Download],
  disabledActionReason: 'Download is restricted for your role. Contact your administrator',
});
Treat this as UX

The docs present these attributes as the way to control menu actions in the browser. They do not use the phrase "not a security boundary" and do not describe enforcement for them. This guide's position is that hiding a button does not remove the capability: a user who holds the privilege can still reach it through other paths, such as the REST data and Report APIs, which check privileges. Pair every hidden action with the matching privilege. docs

Exfiltration pathUI controlReal enforcement
Raw data download (CSV, XLSX)Hide Action.Download, Action.DownloadAsCsv, Action.DownloadAsXlsxWithhold CAN_DOWNLOAD_DETAILED_DATA
Image and PDF exportHide Action.DownloadAsPng, Action.DownloadAsPdfWithhold CAN_DOWNLOAD_VISUALS
Schedules and sharingHide Action.Schedule, Action.ShareRole privileges such as JOBSCHEDULING ("Can schedule for others"), plus content sharing rules docs
Export or import of TMLHide Action.ExportTML, Action.ImportTMLKeep developer and admin privileges off end users. The docs do not name a TML-specific privilege.

Granular download privileges replace the legacy DATADOWNLOADING ("Can download Data"): when enabled, that privilege ceases to exist, previous holders receive both new ones, and non-holders receive neither. With granular RBAC on, the Answer Report API requires CAN_DOWNLOAD_DETAILED_DATA for PDF, XLSX and CSV and CAN_DOWNLOAD_VISUALS for PNG, while the Liveboard Report API requires CAN_DOWNLOAD_DETAILED_DATA for XLSX and CSV and CAN_DOWNLOAD_VISUALS for PDF and PNG. The data APIs (searchdata, Liveboard and Answer data) need DATADOWNLOADING, or CAN_DOWNLOAD_DETAILED_DATA when granular privileges are on. docs docs docs GA The RBAC page still labels both privileges Early Access, "not enabled by default", and says to contact ThoughtSpot Support to enable them, while What's New (August 2026) says they are generally available for RBAC-enabled clusters and the 26.8.0.cl REST changelog lists them for the roles endpoints. The docs conflict; confirm on your cluster. docs docs

For AppEmbed navigation, the V3 (HomePage.ModularWithStylingChanges) and V4 (HomePage.Focused, with PrimaryNavbarVersion.Sliding) home experiences offer showPrimaryNavbar, hideApplicationSwitcher, disableProfileAndHelp, hideOrgSwitcher, hideNotification, hideObjectSearch and hideHamburger. These are also UI-level. docs

Go deeper: token and secret hygiene about 3 min

secret_key is an admin login

Docs call it "equivalent to an admin-level login for the token request REST APIs". It can mint a token for any user. Store it in a secret manager, never ship it to the browser. Front-end examples in the docs are for testing only. docs docs

Derive the user server-side

The browser must not be able to edit the token request or ask for another user's token. Take the username from your own authenticated session. The docs describe the token request service as returning a login token for the user signed in to your web app. docs

Token lifetime is your trade-off

validity_time_in_sec defaults to 300 (docs). Shorter narrows the window if a token leaks and means more mint calls. Longer means fewer calls and a wider window. With autoLogin: true the SDK re-logs the user in when the session expires. The right figure is your risk call. docs docs

Cookieless is the documented default

The docs recommend AuthType.TrustedAuthTokenCookieless, which avoids third-party cookie blocking and works only with REST v2 tokens. The docs we checked do not give a security reason to stay cookie-based, so treat that as a compatibility choice. docs docs

  • Token side effects such as auto_create and persisted values happen when a token is created, not used. Do not pre-generate tokens you may discard. docs
  • Just-in-time creation (auto_create): on saves provisioning work and lets a token request create users, off keeps creation under your control. Auto-created groups are created with default options (the docs; field reports say with no privileges Field-observed), and /token/custom sets groups only at creation. docs
  • You can revoke tokens on sign-out with POST /api/rest/2.0/auth/token/revoke. docs
  • Orgs can have their own secret key (9.2.0.cl+; contact ThoughtSpot Support to enable). A token request made with a per-Org key logs the user into the Org tied to that key. docs docs
MFA: move to token auth first

MFA for local authentication (with IAMv2) shipped as Beta in 10.6.0.cl and is disabled by default. With it on, REST calls that use basic authentication to session/login and the token/object, token/full and token/custom endpoints return an error, which the release notes flag as a breaking change. Embedded users on basic auth are advised to move to AuthType.TrustedAuthTokenCookieless. Before turning MFA on, make sure your embed and REST integrations use token-based (trusted) auth with secret_key. Beta docs docs docs The docs do not say whether MFA has left Beta in later releases; check current status.

Client-side request interception is not a control

interceptUrls (SDK 1.43.0+ · TS 10.15.0.cl+) with EmbedEvent.ApiIntercept lets your app modify or block requests in the browser, which can help with sensitive data handling, but it runs on the client. Do not use it in place of RLS. docs docs

Go deeper: your call, with trade-offs about 3 min

Items below are context-dependent. The docs give the mechanism, and the rest is our reading, labelled.

DecisionFits whenCostsRiskWhat you give up
Block non-embed accessNo user needs the full ThoughtSpot UISelective access setup for exceptionsOff: users with a login can open the full app (docs: it is off by default in the UI)Standalone use for support or analyst users
Withhold download privilegesData must not leave through filesRole design, and users lose a feature they may expectGranted: users can export raw data. Status of granular privileges conflicts across docs, see the action surface panelSelf-service export
Short token lifetimeTokens might be exposed to scripts or logsMore mint calls, so more load on your token serviceLong: wider replay window. Short: refresh failures can interrupt usersSimplicity
Cookieless authBrowsers block third-party cookies (the docs reason)Your getAuthToken must return a fresh token on each callCookie-based: third-party cookie blocking can break sessionsLittle, per the docs we checked
auto_createUsers appear first at login and you can govern that creationGoverning who can be createdOn: a token request can create users, so the secret_key matters moreControl over creation timing
Per-Org secret keyOrg per customer, and you want key separationContact Support to enable (docs); more keys to vaultOne shared key: one leak affects every Org that uses it (our reading)Fewer secrets

Privileges worth governing

PrivilegeWhat it allows (docs)
ADMINISTRATIONRequired for cluster-level SAML and script-src settings, and for fetching audit logs. Users with admin privilege are not restricted by the non-embed block. docs
DEVELOPERAlongside ADMINISTRATION, allows calling the security-settings configure endpoint (REST reference). Developer users are not restricted by the non-embed block.
BYPASSRLSCreate, edit or delete RLS rules, and enable or disable Bypass RLS on a Model. docs
CAN_MANAGE_VARIABLES, CAN_CREATE_OR_EDIT_CONNECTIONSManage formula variables, and manage connections to warehouses. docs

Our guidance: keep these off embed end users, keep holder lists short, and check them on a schedule against the audit log. The secret key rotation cadence is not described in the docs we checked, so set it from your own policy.

Firm rules (doc-stated or one-sided risk): treat hidden actions as UX and enforce with privileges; review security settings for each Org after creating it, because CORS and non-embed blocking can differ per Org; never call the token API from front-end code; never put catch-all hosts such as https://*.com in CSP or CORS; do not assume hiding Download blocks the Report API.

Regulated: Our guidance, to weigh against your auditors' view: remove download, schedule and share through privileges and actions, choose a short token lifetime, decide on just-in-time creation deliberately, and revisit block-non-embed exceptions on a schedule. Network controls such as IP allowlisting are not covered by the developer docs we searched, so ask ThoughtSpot Support.

Multi-tenant SaaS: With an Org per customer, remember per-Org overrides for CORS and non-embed blocking, and weigh per-Org secret keys. CSP hosts are cluster-wide, so list every customer-facing origin there.

0

SDK lifecycle and events

When to call what, in what order, and how your page and the embedded iframe talk without racing.

Your code Embed object (SDK) Iframe (ThoughtSpot) init(config) (once) new Embed() + .on(...) render() create iframe Init, then AuthInit Load (frame area ready) LiveboardRendered / Data / Spotter* Subscribed (host listener ready) trigger(HostEvent.X, payload) promise resolves with response destroy() remove iframe
  1. init once
    init() sets the SDK up globally and starts auth. Call it once, early, at app or module level. Embeds do not need to wait for authentication to finish; the SDK handles it. The returned emitter reports AuthStatus (SUCCESS, FAILURE, LOGOUT, SDK_SUCCESS). docs docs
  2. Create the embed and subscribe
    new LiveboardEmbed(el, cfg), then attach .on(EmbedEvent.Error, ...) and your other handlers. Register EmbedEvent.Error before render(). docs .on() is chainable.
  3. render
    render() returns a promise for the embed. Treat it as “iframe mounting”, not “content ready”. Calling trigger() before render() returns null. docs
  4. Init and AuthInit
    Init fires at the start of loading. AuthInit fires on successful SSO authentication and carries { data: { userGuid } }. docs
  5. Load
    Load fires when the area for embedding is created, not when content has begun or finished loading. Do not hide your spinner here. docs
  6. Better readiness signals
    Use LiveboardRendered (LiveboardEmbed only, SDK 1.9.1) for Liveboards; the docs say it fires when the Liveboard or visualization container loads, and the reference calls it the Liveboard shell, so do not assume every visualization has drawn. For Spotter use events such as SpotterLoadComplete (conversation created) or SpotterResponseComplete (response finished streaming). docs
  7. Subscribed
    EmbedEvent.Subscribed fires when a host-event listener is registered. Wait for it before dispatching host events at initial load, especially with Spotter, to avoid a race. The routing guide describes the signal as emitted when the v2 framework is enabled. New docs docs docs
  8. trigger returns a promise
    trigger(messageType, data?, context?) returns a Promise. Getters such as GetFilters, GetParameters, GetTabs deliver their result through the promise, not a callback. A trigger() that times out (30 s) resolves with an error. docs docs
  9. destroy
    destroy() tears down the embed and its iframe. Do it on route change or unmount and before rendering into the same container again. docs
Subscribe before render, send after Subscribed, destroy before you re-render.

Subscribe before render, send host events after Subscribed, and destroy before you re-render.

EmbedEvent

Iframe to you

Subscribe with embed.on(EmbedEvent.X, handler) to react to loads, errors, clicks, saves, filter changes and custom actions.

or
HostEvent

You to iframe

Send with embed.trigger(HostEvent.X, payload) to change filters, switch tabs, start a search, export or navigate.

HostEvent v1 is deprecated: you choose when v2 goes live

The docs list the v1 host-event framework as deprecated at 26.10.0.cl (October 2026) with end of support in March 2027, and say v2 replaces its default behavior. The context-aware routing page still calls v2 beta and disabled by default. When to turn v2 on in production is a trade-off, laid out in the migration panel below. docs docs Deprecated

Your page’s EmbedEvent.Load handler fires. What can you assume?

The docs say Load fires when the area for embedding is created, not when content has started or finished loading. Use LiveboardRendered for content and EmbedEvent.Subscribed for host-event readiness.

Go deeper: the safe order in code, do and don't about 2 min

Anything you send before the iframe is ready can be dropped or race with loading. This is the safe order.

import { init, LiveboardEmbed, EmbedEvent, HostEvent, AuthType } from '@thoughtspot/visual-embed-sdk';

// 1. Once per page load.
const auth = init({ thoughtSpotHost, authType: AuthType.TrustedAuthTokenCookieless, getAuthToken });

// 2. Per embed: create, subscribe, render.
const embed = new LiveboardEmbed('#container', { liveboardId });
embed.on(EmbedEvent.Error, (e) => {
  // Payload shapes differ by source: { error }, { code, message }, { offlineWarning },
  // or a typed filter error. Read defensively, log, then decide per category.
  const p = e?.data ?? e;
  logEmbedError({ embed: 'sales-board', code: p?.code, errorType: p?.errorType,
                  severity: p?.severity, message: p?.message ?? p?.error ?? p?.offlineWarning });
});
// Documented per-event form: fires when that host event's listener is registered.
embed.on(`${HostEvent.GetFilters} Subscribed`, async () => {
  // 3. Safe to send this host event now.
  const filters = await embed.trigger(HostEvent.GetFilters);
});
await embed.render();

// 4. Before re-rendering into the same container, or on unmount:
embed.destroy();

The docs’ own handler reads payload.error, payload.code and payload.offlineWarning directly, while the events page shows { type, data: { errorType, error } }, so the sketch above reads both. logEmbedError is your function. docs docs

  • Call init() once, at app boot or module scope.
  • Return a fresh token from getAuthToken() every call. docs
  • Prefer init-time config (runtimeFilters, flags) over post-load host events when the value is known at render (the reference page for the view configs says so).
  • Handle EmbedEvent.Error, AuthExpire, and NoCookieAccess explicitly.
  • Our guidance: keep init() out of components that re-render. The docs’ React samples call it at module level.
  • Do not destroy() on every EmbedEvent.Error. The docs say not all errors are fatal and to inspect first. For an SDK or configuration error the docs say to fix the config and recreate the embed, because that instance is blocked from rendering again. docs
  • Do not treat Load as “content loaded”.
Go deeper: migrating off HostEvent v1 about 3 min

The v2 framework adds stricter payload validation and context-aware routing. The deprecation notice says the default behavior of host events will be replaced by the v2 framework in 26.10.0.cl. The docs conflict on this: the routing page still calls v2 beta and disabled by default, and the migration guide has you enable it with useHostEventsV2: true (it no longer carries a Beta label, and says migrating does not introduce breaking changes). docs

  1. Inventory every embed.trigger(HostEvent.*) call in your code, including ones in shared wrappers.
  2. In dev and staging only, set useHostEventsV2: true in the embed’s view config (for example the options you pass to new LiveboardEmbed()); it is not an init() option. SDK 1.45.2+ · TS 26.3.0.cl+ docs
  3. Run your flows and watch EmbedEvent.Error for payload validation failures (code HOST_EVENT_VALIDATION). v2 validates payloads strictly. docs
  4. Check each event against the current context. A host event aimed at the wrong layer returns a context error instead of acting. Read the context with getCurrentContext() and pass a ContextType as the third trigger() argument where needed. docs
  5. Fix payloads and ship the fixes. Then choose your production timing (options below).

Never set shouldBypassPayloadValidation: true in production. docs These steps are our summary of the migration guidance; follow the linked guide for the authoritative checklist.

When to turn v2 on in production: options

The docs ask you to try v2 in a non-production environment first. Production timing is not prescribed, and the docs contradict each other on v2’s status, so these are our reading of the trade-offs.

OptionFits whenWhat it costsRiskWhat changes the answer
Opt in now (useHostEventsV2: true)You have test coverage over your host events and want strict validation earlyMigration work now; payload fixesThe routing page still labels v2 beta, so behavior may still changeA staging cluster on the same release as production
Wait for the default to changeFew trigger() call sites, or a release freezeLower effort now; a larger one-step change laterThe deprecation notice says the default behavior is replaced by v2 at 26.10.0.cl, so waiting does not mean staying on v1 behavior. When your cluster reaches that release is not stated in the docs reviewed.Your cluster upgrade date and how many host events you send

Our guidance: run v2 in dev and staging in either case, as the docs advise. Confirm on your own cluster which framework is active before relying on either row. docs docs

Host Events V2 and context routing

The v2 framework routes each host event to the layer that is currently active (a Liveboard, an Answer, Spotter, and so on). Only one layer handles an event. If you aim at the wrong layer you get a context error rather than an action on the wrong object. You can read the context but not set or inject it, and only the top-most layer is active. docs

Beta The routing page still describes v2 as beta and disabled by default (its text has not been updated for 26.10.0.cl), and the migration guide no longer labels it Beta but still has you enable it with useHostEventsV2, while the deprecation notice says v1 is deprecated at 26.10.0.cl and its default behavior will be replaced by v2. The docs conflict and do not reconcile the two. Treat v2 as opt-in until you confirm the behavior on your cluster, and test it now. docs docs

// useHostEventsV2 is a view-config option on the embed, not an init() option.
const embed = new LiveboardEmbed('#container', { liveboardId, useHostEventsV2: true });
await embed.render();

const ctx = await embed.getCurrentContext();   // Promise of a ContextObject
if (ctx?.currentContext?.name === 'spotter') {
  // act on the Spotter layer
}
await embed.trigger(HostEvent.Pin, payload, ContextType.Answer);

ContextType members: Answer, Liveboard, Other, Search, Spotter (SDK 1.45.2 | TS 26.3.0.cl). docs

Go deeper: event cheat-sheet and version traps about 2 min
EventDirectionWhen it fires or what it does
EmbedEvent.Initiframe to youStart of load
EmbedEvent.AuthInitiframe to youSuccessful SSO; payload has userGuid
EmbedEvent.Loadiframe to youEmbed area created, not content loaded
EmbedEvent.LiveboardRenderediframe to youLiveboard or viz container (shell) loaded (LiveboardEmbed only)
EmbedEvent.Subscribediframe to youA host-event listener is registered; safe to send host events
EmbedEvent.Erroriframe to youSDK, auth, API, validation errors; register before render()
EmbedEvent.AuthExpireiframe to youSession timeout
EmbedEvent.NoCookieAccessiframe to youThird-party cookies blocked
EmbedEvent.Data, VizPointClick, CustomActioniframe to youData returned, user clicks a point, custom action invoked
EmbedEvent.FilterChanged, ParameterChangediframe to youUser changed a filter or parameter
HostEvent.UpdateRuntimeFilters, UpdateFilters, UpdateParametersyou to iframeChange filters or parameters; see Filters
HostEvent.GetFilters, GetParameters, GetTabsyou to iframeRead state; the result comes back in the promise
HostEvent.SetActiveTab, Navigate, Searchyou to iframeSwitch tabs, navigate, run a search
HostEvent.DestroyEmbedyou to iframeTells the iframe to release resources before teardown

Sources: EmbedEvent HostEvent load events. HostEvent.DestroyEmbed is SDK 1.41.0 | TS 10.12.0.cl; EmbedConfig.waitForCleanupOnDestroy makes destroy() wait for it. docs

Check each event’s version badge

Event members carry their own minimum SDK and cluster version. For example EmbedEvent.SpotterResponseComplete is SDK 1.51.0 | 26.9.0.cl in the reference. If your cluster is older, the event never fires and nothing tells you. docs

Breaking change in SDK 1.48

HostEvent.getExportRequestForCurrentPinboard (the enum member is lowercase g; the changelog writes it capitalized) now responds with { data: { v2Content }, type }. Read result.data.v2Content, not result.v2Content. docs

Go deeper: error handling, observability, testing, and SDK version pinning about 5 min
SignalMeaningThe docs’ recovery action
EmbedEvent.Error with { error }SDK or configuration errorFix the configuration, recreate and render the embed
AuthStatus.FAILUREToken or login failed in the SDK layerCheck getAuthToken and credentials; suppressErrorAlerts for your own UI
EmbedEvent.AuthFailureAuth failed in the iframeShow a login prompt, recreate and render
EmbedEvent.AuthExpire, IdleSessionTimeoutSession timed outautoLogin: true for a transparent refresh, or prompt re-authentication
EmbedEvent.NoCookieAccessThird-party cookies blockedSwitch to cookieless trusted auth, or ignoreNoCookieAccess: true to suppress the alert
Duplicate tokengetAuthToken returned a reused tokenAlways return a fresh token
{ offlineWarning }Browser went offlineShow offline UI, reconnect and reload
HOST_EVENT_VALIDATIONYour host-event payload failed validationFix the payload; use useHostEventsV2: true in development
trigger() resolves with an Error (30 s)Host event timed outCheck the iframe is still mounted; reload() if needed

Source: error-handling guidelines. The page treats all other error categories (filters, fullscreen, API) as log-and-inspect. Structured enums ErrorDetailsTypes, EmbedErrorCodes, and the EmbedErrorDetailsEvent type arrived in SDK 1.45.0. docs

Observability: what to log

EmbedEvent.Error is per embed component and is not cached or replayed, so a handler registered after the error never sees it. docs The docs say to log errors for diagnostics and not to show raw error objects or stack traces to users. docs

CaptureSourceWhy
code, errorType, messageEmbedErrorDetailsEvent, enums EmbedErrorCodes, ErrorDetailsTypes docsMachine-readable categories for dashboards and alerts
severity (SEV1 cannot render, SEV2 degraded, SEV3 everything else, including validation of your own input) NewThe error-handling page says it arrives in 26.10.0.cl and SDK 1.53.0 and asks you to review handlers that treat all errors alike. docsSeparates pages-the-on-call from log-and-ignore. Absent on older versions, so treat it as optional. Sources differ: the SDK reference lists SDK 1.52.0 / 26.9.0.cl, while the error-handling page announces 26.10.0.cl / 1.53.0, so test on your cluster before relying on it. Once present, the page says SEV1 errors may require tearing down the embed while SEV2 and SEV3 are log-and-continue; the same page also says "do not destroy the embed on errors", and the docs do not define which errors are SEV1.
AuthStatus from the init() emitter, AuthInit (carries userGuid)Lifecycle aboveTells auth failures apart from render failures
Init, Load, LiveboardRendered timestampsLifecycle aboveOur guidance: a load-time metric per embed
logLevel in init()Default LogLevel.ERROR docsControls what the SDK prints to the console

Correlation. The docs do not define a correlation id. A common approach (our guidance, not doc-stated) is to tag every log line with your own embed name, the object id, your app user or session id, the SDK version, and the cluster release, so an embed error can be matched to your backend token-mint log. Check your privacy policy before logging userGuid or message text.

Testing. The docs suggest simulating failures such as network loss and invalid filters, and enabling useHostEventsV2: true in development or staging. docs The Visual Embed Playground lets you try events and preview changes. docs How many non-production clusters or Orgs you run is your call. For moving content between them, the Git integration docs say to use one branch per Org and one repository per project. docs

SDK version pinning and upgrades

ThoughtSpot ships a new SDK version with each major cloud release and tells you to use the recommended version. Every symbol carries two minimums, the SDK and the cluster. docs

Cluster releaseRecommended SDK
26.4.0.cl1.47.0+
26.5.0.cl1.48.0+
26.6.0.cl1.49.0+
26.7.0.cl1.50.0+
26.8.0.cl1.51.0+

Source: What’s new. Use the row for your cluster; newer SDK features may not exist on an older cluster.

The docs state that you should always use the recommended SDK version and review the changelog for breaking and deprecated items. docs How you get that version into your page is a trade-off.

DeliveryFits whenWhat it costsRisk
npm with a lockfileYou have a build and a release processYou bump the version yourselfFalling behind the cluster’s recommended version
Vendored file (self-hosted copy)You need a fixed artifact or a strict CSPYou refresh and host itSame as above, plus a stale copy nobody owns
Unversioned CDN URL (the getting-started sample)Prototypes and demosLeast effort; you receive whatever is newestBehavior can change under you with no release step in your pipeline docs

Upgrade cadence has the same shape. Following each cloud release keeps you on the table’s recommended row and means a regression run every cycle. Lagging a release saves test cycles but you may miss fixes, and the docs reviewed do not state whether an older SDK is supported on a newer cluster. Our guidance: stage every bump against a non-production cluster first. Do not assume a 26.10 or 26.11 feature exists on your cluster because the reference lists it; every symbol carries both minimums.

Deprecated names keep working for a short period, so the timing is yours, but each has a stated end of support. Examples: AuthType.SSO (deprecated in SDK 1.13.0; use AuthType.SAMLRedirect), searchQuery to searchOptions, isLiveboardStylingAndGrouping to isLiveboardMasterpiecesEnabled. docs docs

Go deeper: framework wrappers, multiple embeds, and pre-render about 4 min

All three options below sit on the same lifecycle. They differ in who owns create, subscribe, render and destroy.

OptionFits whenWhat it costsRisk or what you give up
Plain classes (new LiveboardEmbed())Any framework, or noneYou write the lifecycle helperA forgotten destroy() leaves nodes and handlers behind
SDK React components (@thoughtspot/visual-embed-sdk/react)React app; props for config, on<EventName> props for embed events, useEmbedRef for trigger()React 16.8+ and React DOM as peer dependencies; Webpack 4 needs a different import path docs docsThe pages reviewed do not describe behavior on prop changes or double mounts. Test re-render and unmount paths yourself.
Your own thin wrapperSeveral teams or frameworks share one standardYou maintain itYou diverge from the docs’ samples

Several embeds on one page. The docs suggest putting several visualizations on one Liveboard and embedding that, rather than many embeds. docs If you do need several, each embed registers its own EmbedEvent.Error handler, and queueMultiRenders: true (default false) starts each render after the previous one finishes, trading total load time for lower browser load. docs

Pre-render. The docs compare five loading strategies. docs

StrategyPerceived speedCost
Normal renderSlowestNo reuse; no wasted loads
PrefetchSlight gainBrowser cache often makes it redundant
Pre-render with Liveboard IDFastestLoads data and assets even if never opened
Pre-render without IDModerateLoads some assets unused; data waits until needed
On demandFast on revisit, slow on first visitThe docs call it the best balance of speed and efficiency

A pre-rendered embed is reused only if the real embed’s config matches, and pre-rendered iframes are attached to the page body unless you set preRenderConfig.containerSelector. docs More detail is in the Performance section.

Multi-tenant SaaS: wrap init, embed creation, and teardown in one shared helper so every team follows the same order. One shared helper is a common choice: a single place to add the v2 flag and error logging, at the cost of one more layer to maintain.

AI and Spotter: wait for EmbedEvent.Subscribed before sending the first prompt or host event, and keep tokens valid long enough for long conversations. In Spotter embed, several host events require a vizId; without it the trigger fails with a missing-visualization-context error. docs

0

Filters & parameters: four mechanisms, no security

Embedded ThoughtSpot has several ways to narrow data. This section covers which to pick and how to drive it predictably.

⚙Runtime filtersHost-imposed context. Not shown as chips.
▦Runtime parametersWhat-if values. Hidden by default.
☰Liveboard filtersVisible chips the user can edit.
☺User’s own filtersChips, cross filters, search filters.

Pick by intent, and remember that none of the four is a security boundary. Use row-level security for that. docs

Which mechanism?

Which mechanism?

UpdateRuntimeFilters appends: try it

HostEvent.UpdateRuntimeFilters appends to the existing runtime filters, per column. A column you send is replaced, a column you omit stays, and a column sent with values: [] is cleared. SDK 1.9.0 · TS 8.1.0.cl GA docs

Try it: UpdateRuntimeFilters appends per column
Why "send []" is a trap

Developers often try to "reset" with an empty list. It is a no-op. To clear everything, resend every active column with values: [], or destroy and re-render the embed with a fresh runtimeFilters config.

The embed shows Region = West and Product = Bikes. You trigger UpdateRuntimeFilters with only [Region = East]. What do you see?

The event appends per column: a column you send is replaced, a column you omit stays. To drop Product you must resend it with values: [].

Security belongs in the data layer

Anything the host sends from the browser, the user can inspect and change. Enforce tenant and entitlement limits with row-level security and token-bound variables (see the auth and security sections), never with runtime filters docs.

Go deeper: the four kinds compared about 3 min

A single Liveboard can be narrowed by several independent mechanisms at once. The docs describe a wider stack of layers (row-level security, Model filters, runtime filters and parameters, Liveboard filters, cross filters, search query filters) docs. Each has its own API and its own visibility to the user.

KindAPIVisible to user?PersistenceSecurity
Runtime filtersView config runtimeFilters; HostEvent.UpdateRuntimeFilters docsNot as chips. The docs say runtime filters do not display as UI filter components docs.Not saved in the object; applied dynamically at runtime docs. A re-render starts from the config you pass.Not a boundary. The docs say to use RLS.
Runtime parametersView config runtimeParameters; HostEvent.UpdateParameters docsHidden by default: runtimeParameters hide the chip, and UpdateParameters defaults isVisibleToUser to false (since TS 10.14.0.cl). Pass isVisibleToUser: true to keep a visible chip docs.Not saved to the object. The Parameter must already be configured in the Model docs.Not a boundary
Liveboard (visible) filtersHostEvent.UpdateFilters, HostEvent.GetFilters docsYes, as filter chipsChips on the Liveboard that users can add or modify docs. The docs do not state a lifetime for host-updated chips. preventLiveboardFilterRemoval makes chips read-only (no X buttons) docs.Not a boundary
User's own filtersThe filter UI inside the embed (chips, cross filters, search filters)YesDepends on the object and the user's privileges; the docs do not give one rule. Search query filters are saved in the object's TML docs.Not a boundary
Visible filters are a separate mechanism

Runtime filters and Liveboard filters are different systems. UpdateFilters changes the chips users see and can edit. Runtime filters pre-filter the object from the host side. The docs list them as separate layers with separate host events docs docs. Pick one on purpose, and do not expect one to clear the other.

Go deeper: what "append" means in practice about 2 min

Observed on ThoughtSpot 26.8.0.cl with SDK 1.49.0 Field-observed: it works per column. A column you send is replaced with the new values. A column you omit is left alone. A column sent with values: [] is cleared; the docs also show clearing a runtime filter this way docs. Sending an empty array of filters changes nothing. The docs say filters are "appended" but do not spell out the per-column replace rule or the empty-array no-op, so treat those as observed and re-test after upgrades.

The docs also state the event resets the object to its original state, so user changes such as drills are cleared. It is supported in LiveboardEmbed and AppEmbed docs.

Go deeper: operators about 1 min

A runtime filter is { columnName, operator, values }. If a Model column has an alias, use the base column name docs. The members of RuntimeFilterOp docs docs:

GroupMembers
EqualityEQ, NE
ComparisonLT, LE, GT, GE
Set membershipIN, NOT_IN
RangeBW (non-inclusive), BW_INC (inclusive of both ends), BW_INC_MAX (inclusive of the higher value), BW_INC_MIN (inclusive of the lower value)
TextCONTAINS, BEGINS_WITH, ENDS_WITH

The range operators take two values; EQ and LE take one, and IN takes a list docs. The bound semantics above come from the enum definitions. Still test range filters on your cluster, especially on date columns.

Go deeper: dates and the push-everything helper about 4 min

The docs say DATE and DATE_TIME runtime-filter values must be given as epoch time: seconds since 1 Jan 1970 00:00:00 UTC docs. Send them as numbers, computed in UTC. A string epoch being silently ignored, and local midnight landing on the wrong day for anyone not in UTC, are what we observed Field-observed.

The docs disagree with each other

The runtime-filters page shows an epoch as a string (values: ['1656680400']) in its multi-filter SDK example docs. The UpdateFilters docs say the opposite: epochs must be numbers, not strings docs. On the instance we tested, strings were ignored for runtime filters Field-observed. Send numbers, and verify on your cluster.

// 'YYYY-MM-DD' from a date input -> epoch SECONDS at 00:00 UTC, as a Number
function utcEpoch(ymd) {
  const [y, m, d] = ymd.split('-').map(Number);
  return Date.UTC(y, m - 1, d) / 1000;        // not new Date(ymd).getTime()
}

// inclusive range, end of day included
const start = utcEpoch('2026-01-01');
const end   = utcEpoch('2026-01-31') + 86399;

liveboardEmbed.trigger(HostEvent.UpdateRuntimeFilters, [{
  columnName: 'Order Date',
  operator: RuntimeFilterOp.BW_INC,
  values: [start, end],                       // numbers, never strings
}]);

If you keep filter state as strings (in a form or a URL), convert at the moment you trigger, not when you store.

Because the event appends per column, "send what changed" leaks stale columns. A column the user removed in your UI stays applied in ThoughtSpot until you clear it. Keep one source of truth in your app, and on every change push the whole desired set plus clears for anything that was applied before and is now gone.

let applied = new Set();                      // columns currently applied in the embed

function pushRuntimeFilters(embed, desired) { // desired: [{ columnName, operator, values }]
  const wanted = new Set(desired.map(f => f.columnName));
  const clears = [...applied]
    .filter(col => !wanted.has(col))
    .map(columnName => ({ columnName, operator: RuntimeFilterOp.EQ, values: [] }));

  embed.trigger(HostEvent.UpdateRuntimeFilters, [...desired, ...clears]);
  applied = wanted;
}

// Re-render: runtimeFilters in the config start from scratch, so reset the tracker.
function onRerender() { applied = new Set(); }

Two more rules. Always call embed.destroy() before re-rendering, and reset your tracker when you do. And because the event also resets the object to its original state, any drill the user made is cleared on every push docs.

Go deeper: limits, the four side by side, do and don't about 4 min
  • The runtime filters operation returns an error if the URL exceeds 2000 characters docs. Since SDK 1.45.0 runtime filters are no longer in the iframe URL by default; the excludeRuntimeFiltersfromURL flag controls it docs.
  • Overriding existing filter values with runtime filters while exporting a Liveboard errors docs.
  • Limits differ by path. URL-based runtime filters are capped at exactly 50 and the cap cannot be configured. Runtime filters passed through the SDK runtimeFilters property have a configurable limit whose default and maximum is 1,000 (contact ThoughtSpot Support to change it). Filters past either limit are dropped silently, with no error, warning, or event docs. Count them before you push, and log the count on your side, because nothing else will tell you (our guidance).
  • Filter failures that do happen surface through EmbedEvent.Error with typed errors such as NON_EXIST_FILTER, INVALID_DATE_VALUE and INVALID_OPERATOR, and invalid host-event payloads through codes such as UPDATEFILTERS_INVALID_PAYLOAD. Log them with the filter you sent, as in the lifecycle observability table. docs docs
  • On UpdateFilters, an empty values array removes the values but leaves the chip, and clearing date filters this way is not supported docs.
  • In Spotter, runtime filters keep applying to all answers in the same conversation session unless explicitly overridden. SDK 1.41.0 · TS 10.13.0.cl+ docs
  • Parameters: once a parameter chip is hidden through runtimeParameters, a URL override, or isVisibleToUser: false, it stays hidden for the embed's lifetime unless you create a new embed object docs. In Spotter, parameter values cannot change mid-conversation docs. SDK 1.25.0 · TS 9.2.0.cl+ (UpdateParameters: SDK 1.29.0 · TS 10.1.0.cl)
  • UpdateFilters uses date types such as EXACT_DATE, EXACT_DATE_RANGE, LAST_N_PERIOD, with YYYY-MM-DD or YYYY/MM/DD strings, or numeric epochs (numbers, not strings) docs. SDK 1.23.0 · TS 9.4.0.cl+
// 1. Runtime filter at init: host-imposed context
new LiveboardEmbed(el, { liveboardId, runtimeFilters: [
  { columnName: 'Region', operator: RuntimeFilterOp.IN, values: ['West'] } ] });

// 2. Runtime parameter
liveboardEmbed.trigger(HostEvent.UpdateParameters, [{ name: 'Discount', value: 10 }]);

// 3. Visible Liveboard filter chip the user can edit
liveboardEmbed.trigger(HostEvent.UpdateFilters, { filter:
  { column: 'Region', oper: 'IN', values: ['East'] } });

// Read what is applied (resolves with data, not via callback)
const current = await liveboardEmbed.trigger(HostEvent.GetFilters);

Items marked (our guidance) are judgement, not doc-stated. The rest follow the cited docs.

  • Keep one merged source of truth in the host and push the full desired set (our guidance).
  • Clear dropped columns with values: [].
  • Convert date strings to UTC epoch numbers at trigger time.
  • Use row-level security for tenant and entitlement limits.
  • Use UpdateFilters when users should see and edit the filter.
  • Send only the delta and assume omitted columns are cleared (field-observed, re-test after upgrades).
  • Send [] to "reset everything".
  • Send date epochs as strings, or build them from local midnight.
  • Treat runtime filters or hidden chips as access control.
  • Expect UpdateRuntimeFilters to preserve a user's drill.

Multi-tenant SaaS: A common pattern is to prefill tenant context with runtime filters for a better first view. The tenant boundary must be enforced by RLS or token-bound variables. A user who edits the filter in dev tools must still see only their own tenant.

Internal portal: Passing department or region as runtime filters gives a convenient default; visible filters let people explore. Whether chips should be read-only (preventLiveboardFilterRemoval) trades governance against flexibility.

0

Drill down and drill-through

An embedded Liveboard can go deeper in three ways, from a number to the records behind it. Decide which one you want.

↓Native drill downThoughtSpot's own menu item. Nothing to build.Action.DrillDown
⇨Left-click to a second LiveboardOne click opens a filtered detail board.EmbedEvent.VizPointClick
☰Right-click to your own panelYour app fetches and draws the records.customActions · searchdata

They look alike to a user and share almost no code, so decide which one you want first.

Which drill pattern do I need?

What each pattern costs

Native drill down

  • Build effort: none.
  • Security: ThoughtSpot applies row-level security.
  • Gives up: your own layout and links.

Second Liveboard

  • Build effort: one event handler and a Back button.
  • Security: runtime filters are visible, not a boundary.
  • Gives up: raw rows and row links.

Your own panel

  • Build effort: highest. Server route, paging, UI.
  • Security: you own the export surface and the audit trail.
  • Gives up: nothing, but you maintain it.

Our guidance, not doc-stated: the panel is the only pattern where you also decide row volume, caching and logging. Detail is in the panels below.

Left-click: open a second Liveboard

The click reports a mark, you read the board's filters, and a second Liveboard opens with both.

Your apphost pageThoughtSpot embedsummary Liveboard iframeEmbedEvent.VizPointClickvizId + clickedPoint (the attributes of the clicked mark)HostEvent.GetFilterswhat has the user already narrowed on this board?filters currently on the boardreturned as the trigger() promise resolvesdestroy(), then new LiveboardEmbed({ runtimeFilters })second Liveboard opens with click + board filters
  1. The user left-clicks a mark
    You subscribed to EmbedEvent.VizPointClick GA SDK 1.11.0+ · TS 8.3.0.cl+ docs. It carries vizId and clickedPoint. While you are subscribed, ThoughtSpot shows no left-click menu and right-click still works Field-observed (ThoughtSpot 26.8.0.cl, SDK 1.49.0).
  2. Your app asks what the user already filtered
    Trigger HostEvent.GetFilters and await the promise GA SDK 1.23.0+ · TS 9.4.0.cl+ docs. Without it, the detail board loses the user's date range or region.
  3. The embed returns the board's filters
    Optional: if the call fails on your build, fall back to the clicked point alone.
  4. Destroy, then open the second Liveboard
    Always destroy() first. Pass the merged filters as runtimeFilters docs, and add a Back button in your page.
Left-click never leaves the SDK: no REST call, only events and a second embed. If the board filter and the click hit the same column, the click wins.

Right-click: your own detail panel

Nothing reaches your app until the user picks your menu item. Then your server queries the records.

Your appbrowser + your serverThoughtSpot embedLiveboard iframeThoughtSpot RESTsearchdata APIcustomActions in the view configset once, when the embed is builtUser right-clicks a valuenative menu opens, plus your itemEmbedEvent.CustomActionthe clicked row: attributes + measuresPOST /api/rest/2.0/searchdatafrom your server, with the viewer's token, filtered by every attribute on the rowmatching rowsrun as the viewer, so row-level security still appliesYour panel rendersoutside the iframe, in your own UI
  1. You declare the action when you build the embed
    A code-based custom action with CONTEXTMENU and CustomActionTarget.VIZ SDK 1.43.0+ · TS 10.14.0.cl+ docs.
  2. The user right-clicks a value
    ThoughtSpot's menu opens with your item appended. Nothing reaches your app yet Field-observed.
  3. The user picks your item: EmbedEvent.CustomAction
    The payload has id, vizId, session and contextMenuPoints docs.
  4. Your server calls searchdata
    POST /api/rest/2.0/searchdata docs, available from TS 9.0.0.cl+. Build the query on the server, filtered by every attribute on the row.
  5. ThoughtSpot returns the matching rows
    It runs as the token's user, so row-level and column-level security apply. With COMPACT, each row is an array aligned to column_names Field-observed.
  6. Your panel renders
    Outside the iframe. Use textContent for every value and allow only http and https links.
Rows never pass through the embed. Your page asks, so the panel can look like your product.

What arrives with the click

selected* is the cell that was clicked, deselected* is the rest of its row. Read both.

EmployeeLynn T.TerritoryPacificDay(Order Date)1769644800Quota150,000Total Sales Amount134,280.98One table row. The user right-clicks the measure cell on the right.selectedselectedMeasuresdeselecteddeselectedAttributes (Employee, Territory, Date) + deselectedMeasures (Quota)selectedAttributes is emptyCode that reads only selectedAttributes finds no scope on this click.

A measure-cell click leaves selectedAttributes empty. The row's attributes land in deselectedAttributes Field-observed.

BikesSkisBootsHelmetsBars of Total Sales Amount by Product, one series per Territory.clickedselectedAttributesProduct = Skis, Territory = WestselectedMeasuresTotal Sales Amountdeselected*empty: the mark is the whole point

A bar puts everything in selected*. Code that handles only this case works on charts and fails on tables Field-observed.

Where each value on the panel comes from

A. From the click

The measure and every attribute on the row. No network call.

B. From the query

Every row and cell, the count, and the browser-side sum.

C. From your configuration

Labels, column order, the row link, and the measure the panel is about.

A from the clickThoughtSpot aggregate112,009.82B from the querysum of 64 fetched rows112,009.8219two independent routes agree (to rounding), so the scope was right
The totals check, A against B. Example from testing: 112,009.82 from the click and 112,009.8219 as the sum of 64 order lines Field-observed.

When A and B agree, two independent routes confirmed the scope.

Go deeper: the code, step by step about 5 min
  • Code-based custom actions need SDK 1.43.0+ docs. TS 10.14.0.cl introduced them docs.
  • position: CustomActionsPosition.CONTEXTMENU is valid for VIZ, ANSWER and SPOTTER, not LIVEBOARD docs.
  • Read the action id with payload?.id ?? payload?.data?.id: classic Liveboards use payload.id, the new experience payload.data.id docs.
Shared helpers: read the clicked point, unwrap dates
/** The clicked point, for a left-click and for a context-menu action. */
function clickedPoint(payload) {
  const d = payload?.data ?? payload ?? {};
  const cmp = d.contextMenuPoints;
  return d.clickedPoint
    ?? (Array.isArray(cmp) ? cmp[0] : cmp?.clickedPoint)   // object on tables, array on some surfaces
    ?? d.selectedPoints?.[0]
    ?? null;
}

/** The row's attribute values as [{ column, value }]. Nulls dropped. */
function pointAttributes(point) {
  if (!point) return [];
  const list = v => [].concat(v ?? []);          // docs sample shows a single object, field notes an array
  const attrs = list(point.selectedAttributes).length
    ? list(point.selectedAttributes)
    : list(point.deselectedAttributes);          // table measure-cell click
  return attrs
    .map(a => ({ column: a.column?.name, value: a.value }))
    .filter(a => a.column && a.value != null && a.value !== '' && a.value !== '{Null}');
}

const BUCKET = /^(Day|Week|Month|Quarter|Year)\((.+)\)$/i;

/** "Month(Order Date)" becomes { bucket: 'month', name: 'Order Date' }. */
function splitBucket(column) {
  const m = BUCKET.exec(String(column).trim());
  return m ? { bucket: m[1].toLowerCase(), name: m[2] } : { bucket: '', name: column };
}

/** Epoch seconds (accepts seconds or milliseconds). */
function toEpochSec(v) {
  const n = Number(v);
  return n >= 1e11 ? Math.floor(n / 1000) : n;
}

/** Inclusive last second of a week/month/quarter/year bucket, in UTC. */
function bucketEndSec(bucket, startSec) {
  const d = new Date(startSec * 1000);
  const y = d.getUTCFullYear(), mo = d.getUTCMonth(), da = d.getUTCDate();
  const next = {
    week: Date.UTC(y, mo, da + 7),
    month: Date.UTC(y, mo + 1, 1),
    quarter: Date.UTC(y, mo - (mo % 3) + 3, 1),
    year: Date.UTC(y + 1, 0, 1),
  }[bucket];
  return Math.floor(next / 1000) - 1;
}
import { LiveboardEmbed, EmbedEvent, HostEvent, RuntimeFilterOp } from '@thoughtspot/visual-embed-sdk';

const SUMMARY_LB = '<summary-liveboard-guid>';
const DETAIL_LB  = '<detail-liveboard-guid>';
const DRILL_VIZ  = '<viz-guid>';        // '' = drill from any viz on the board

let embed = renderSummary();

function renderSummary() {
  hideBackButton();
  const e = new LiveboardEmbed('#ts-embed', { liveboardId: SUMMARY_LB });
  e.on(EmbedEvent.VizPointClick, onPointClick);
  e.render();
  return e;
}

/** A clicked attribute becomes a runtime filter the detail Liveboard honors. */
function toRuntimeFilter({ column, value }) {
  const { bucket, name } = splitBucket(column);
  if (!bucket) return { columnName: name, operator: RuntimeFilterOp.IN, values: [String(value)] };
  const start = toEpochSec(value);                       // NUMBER, UTC
  if (bucket === 'day') return { columnName: name, operator: RuntimeFilterOp.IN, values: [start] };
  // Month / quarter / year clicks give the START of the bucket: send the whole range.
  return { columnName: name, operator: RuntimeFilterOp.BW_INC, values: [start, bucketEndSec(bucket, start)] };
}

async function onPointClick(payload) {
  const vizId = payload?.data?.vizId;
  if (DRILL_VIZ && vizId && vizId !== DRILL_VIZ) return;

  const clicked = pointAttributes(clickedPoint(payload)).map(toRuntimeFilter);
  if (!clicked.length) return;                           // nothing to scope by

  // Filters the user already set on the board. Optional: fall back to the click alone.
  const source = embed;
  let boardFilters = [];
  try {
    // The docs disagree on the envelope: the SDK reference types it { liveboardFilters, runtimeFilters },
    // the host events page describes an array. Accept both, and both item keys (`column` was observed).
    const res = await source.trigger(HostEvent.GetFilters);
    const list = Array.isArray(res) ? res : (res?.liveboardFilters ?? []);
    boardFilters = list
      .map(f => ({ columnName: f.column ?? f.columnName, operator: RuntimeFilterOp[f.operator] ?? RuntimeFilterOp.IN, values: f.values }))
      .filter(f => f.columnName && Array.isArray(f.values) && f.values.length);
  } catch { /* click only */ }
  if (embed !== source) return;                          // user navigated while we waited

  // One entry per column. The clicked value wins over a board filter on the same column.
  const byCol = new Map();
  [...boardFilters, ...clicked].forEach(f => byCol.set(f.columnName, f));
  openDetail([...byCol.values()]);
}

function openDetail(runtimeFilters) {
  embed.destroy();                                       // always destroy before re-render
  embed = new LiveboardEmbed('#ts-embed', { liveboardId: DETAIL_LB, runtimeFilters });
  embed.render();
  showBackButton(() => { embed.destroy(); embed = renderSummary(); });
}

The GetFilters item shape is the one place docs and field notes differ; see the contradictions below. Column names must exist on the detail Liveboard's Model. If the detail board names a column differently, map the name before sending it.

import { LiveboardEmbed, EmbedEvent, CustomActionsPosition, CustomActionTarget } from '@thoughtspot/visual-embed-sdk';

const ACTION_ID = 'view-records';
const PAGE_SIZE = 100;

const embed = new LiveboardEmbed('#ts-embed', {
  liveboardId: '<summary-liveboard-guid>',
  customActions: [{
    id: ACTION_ID,
    name: 'View records',
    position: CustomActionsPosition.CONTEXTMENU,    // the right-click menu
    target: CustomActionTarget.VIZ,                 // per viz, so the clicked row is sent
    metadataIds: { vizIds: ['<viz-guid>'] },        // scope with vizIds ALONE (keys are OR'd)
  }],
});

embed.on(EmbedEvent.Error, (e) => {
  // Field-observed, not in the docs: a right-click on a viz outside vizIds reports this code.
  if (JSON.stringify(e).includes('VIZ_ACTION_FILTER_VALIDATION')) return;
  showError(e);
});

embed.on(EmbedEvent.CustomAction, (payload) => {
  if ((payload?.id ?? payload?.data?.id) !== ACTION_ID) return;
  const scope = pointAttributes(clickedPoint(payload));   // e.g. [{ column: 'Day(Order Date)', value: 1769644800 }, ...]
  openRecordPanel(scope);
});
embed.render();

async function openRecordPanel(scope) {
  let offset = 0;
  const list = document.querySelector('#record-list');
  list.replaceChildren();

  async function loadPage() {
    const r = await fetch('/api/record-detail', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ scope, offset, size: PAGE_SIZE }),
    });
    const { columns, rows } = await r.json();
    rows.forEach(row => list.appendChild(recordRow(Object.fromEntries(columns.map((c, i) => [c, row[i]])))));
    offset += rows.length;
    // A SHORT page is the only reliable end-of-data signal.
    document.querySelector('#load-more').hidden = rows.length < PAGE_SIZE;
  }
  document.querySelector('#load-more').onclick = loadPage;
  await loadPage();
}

function recordRow(row) {
  const li = document.createElement('li');
  const a = document.createElement('a');
  a.textContent = `${row['Record Id']} · ${row['Customer Name']} · ${row['Amount']}`;   // textContent, never innerHTML
  const href = resolveLink('https://example.com/records/{Record Id}', row);
  if (href) { a.href = href; a.target = '_blank'; a.rel = 'noopener'; }
  li.appendChild(a);
  return li;
}

/** Fill {Column} placeholders and refuse anything that is not http(s). */
function resolveLink(template, row) {
  const url = template.replace(/\{([^{}]+)\}/g, (_, c) => encodeURIComponent(String(row[c.trim()] ?? '')));
  try { const u = new URL(url); return ['http:', 'https:'].includes(u.protocol) ? u.href : ''; }
  catch { return ''; }
}
const DETAIL_MODEL   = '<record-level-model-guid>';             // one row per record
const DETAIL_COLUMNS = ['Record Id', 'Order Date', 'Customer Name', 'Amount'];
const SCOPE_COLUMNS  = new Set(['Order Date', 'Employee Name', 'Territory', 'Region']);   // allowlist

/** MM/DD/YYYY in UTC: the date literal the search parser accepted on the cluster tested. */
function mdy(sec) {
  const d = new Date(sec * 1000), p = n => String(n).padStart(2, '0');
  return `${p(d.getUTCMonth() + 1)}/${p(d.getUTCDate())}/${d.getUTCFullYear()}`;
}

function buildQuery(scope) {
  const clauses = [];
  for (const { column, value } of Array.isArray(scope) ? scope : []) {
    const { bucket, name } = splitBucket(column);          // same helper as the frontend
    if (!SCOPE_COLUMNS.has(name)) continue;                // the browser cannot choose columns
    const clean = String(value).replace(/['\\]/g, '');   // strip quotes AND backslashes: literals are single-quoted
    if (!bucket) { clauses.push(`[${name}] = '${clean}'`); continue; }
    const start = toEpochSec(value);
    if (bucket === 'day') clauses.push(`[${name}] = '${mdy(start)}'`);
    else clauses.push(`[${name}] >= '${mdy(start)}' [${name}] <= '${mdy(bucketEndSec(bucket, start))}'`);  // verify on your cluster
  }
  return [DETAIL_COLUMNS.map(c => `[${c}]`).join(' '), ...clauses].join(' ');
}

app.post('/api/record-detail', async (req, res) => {
  const token = await getTokenForUser(req.user);           // the SAME per-user token the embed uses
  const size  = Math.min(Number(req.body.size) || 100, 1000);   // your own page cap
  const r = await fetch(`${TS_HOST}/api/rest/2.0/searchdata`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({
      query_string: buildQuery(req.body.scope || []),
      logical_table_identifier: DETAIL_MODEL,
      data_format: 'COMPACT',
      record_offset: Math.max(Number(req.body.offset) || 0, 0),
      record_size: size,
    }),
  });
  if (!r.ok) {                                             // log upstream detail server-side only
    console.error('searchdata', r.status, await r.text());
    return res.status(502).json({ error: 'Could not load records' });
  }
  const content = (await r.json()).contents?.[0] ?? {};
  res.json({ columns: content.column_names ?? [], rows: content.data_rows ?? [] });
});

Request fields per the docs. The date literal format, the range form and the shape of COMPACT rows are field findings; see below. The docs' paging example loops until a page returns fewer rows than record_size docs. The cap of 1,000 here is your server's choice, not a ThoughtSpot limit.

  • Scope a right-click action with metadataIds.vizIds alone. The docs describe the scope keys as a union docs.
  • Carry every attribute on the row, from both buckets.
  • Build query_string on the server from an allowlist of columns.
  • Call searchdata with the viewer's token and keep the call on your server.
  • Merge board filters and the click per column, with the click winning.
  • Render values with textContent and allow only http(s) links.
  • Do not set vizIds and modelColumnNames together and expect AND.
  • Do not read only selectedAttributes.
  • Do not treat available_data_row_count as a total.
  • Do not accept raw query text from the browser.
  • Do not use runtime filters to enforce who can see what.
  • Do not treat VIZ_ACTION_FILTER_VALIDATION as fatal.
Go deeper: four surprises, docs vs. field, and the payload tree about 4 min

1. Column scope picks visualizations, not cells

dataModelIds.modelColumnNames limits the action to visualizations built on that column, but it still shows on every cell of a matching table Field-observed. Take the panel's measure from your configuration, not from the clicked cell, and say so if the selection lacks it.

2. The row count is not a total

The REST reference calls available_data_row_count the "total available data row count". On ThoughtSpot 26.8.0.cl it equalled the page size on full pages Field-observed. Treat a short page as the end. Avoid record_size = -1: the docs warn against it for metadata search docs.

3. Dates arrive wrapped

A grouped date arrives as Day(Order Date) with a raw epoch in seconds. Strip the wrapper before filtering (a leftover one returned a 400 "Bad tokens"). A Month(...), Quarter(...) or Year(...) click is the start of the bucket, so send a range.

4. Carry every attribute on the row

One seller, one day, one territory matched 113 order lines only once all three attributes were carried. The seller alone returned roughly ten times the rows Field-observed.

Row volume, paging and server cost
  • Defaults and ceiling. The data APIs default to record_size 10 and return at most 100,000 rows at a time; ThoughtSpot Support can raise it, but large requests can be slow and may time out docs.
  • Each page is its own warehouse query. The docs say each call is an independent SQL query and that a sort clause is needed for stable, non-overlapping pages docs. The sample query above has no sort, so add one for your Model. We have not verified the sort token syntax here.
  • Page size is a trade-off. Small pages feel fast and cost many queries as users scroll. Large pages cost fewer queries but slower first paint and more raw data on screen. The docs' sample uses a small size to show the loop and says 10000 for production. Our default of 100 is a UX choice, not a docs figure.
  • Caching. Not covered by the docs. Caching on your server avoids repeat warehouse queries but copies data out of ThoughtSpot's security model, so scope the key to the user and expire it quickly. Without a cache, every back-and-forth costs a query.
  • Do not set record_size to -1. The docs warn of slow responses and out-of-memory failures docs.
Docs and field observation disagree here

Each item below was observed on ThoughtSpot 26.8.0.cl with SDK 1.49.0. Test on your cluster.

  • Row totals. Docs: available_data_row_count is the total. Field: it equals the page size on full pages (above).
  • Date epochs. Docs example passes a runtime-filter date as a string epoch docs. Field: a string epoch was silently ignored; a numeric UTC epoch in seconds worked. Send numbers.
  • Wrapped dates. Not in the docs: click payloads carry Day(Order Date) with a raw epoch, not a formatted date.
  • Nulls. Not in the docs: nulls arrive as the string '{Null}'. Skip them rather than filtering on them.
  • GetFilters shape. The SDK reference types the response as { liveboardFilters, runtimeFilters } docs, while the host events page describes an array of filter objects docs. Field notes read a flat array whose items use column, not columnName. The code above accepts both envelopes (array and { liveboardFilters }) and both item keys; verify on your cluster.
  • deselectedAttributes shape. The docs sample shows a single object; field notes show an array of { column, value }. The helper accepts both.
  • contextMenuPoints shape. Docs: an object. Field: an object on tables, an array on some other surfaces.
  • Date literal in query_string. [Order Date] = '01/29/2026' worked; '2026-01-29' was rejected; the .daily keyword form returned wrong rows without an error. The format may follow the cluster's locale. The week/month range form was not verified.
The full payload tree (what rides along with a custom action)

Observed on a table-cell right-click, ThoughtSpot 26.8.0.cl with SDK 1.49.0 Field-observed. The CustomActionPayload type documents id, vizId, session, contextMenuPoints and embedAnswerData docs, and the payload examples show the deselected* buckets docs. Treat the inner tree as a convenience, not a contract.

id                      which action was picked
vizId                   the visualization that was clicked
session                 handle for fetching more data
contextMenuPoints
  clickedPoint
    selectedAttributes      [{ column, value }]  the clicked cell's attributes
    deselectedAttributes    [{ column, value }]  the rest of the row
    selectedMeasures        [{ column, value }]
    deselectedMeasures      [{ column, value }]
  selectedPoints[]          same shape, one per selected point
embedAnswerData
  sources[0].header         the Model's name and GUID
  columns[]                 name, type, data type, date bucket
  data.columnDataLite       every value already on screen
  filterGroups              filters applied to the viz
user                        name, GUID, email
permission

Two useful consequences: the source Model is identified for you, and the values already on screen ride along with the click. On some surfaces contextMenuPoints is an array rather than the documented { clickedPoint, selectedPoints } object, so read it defensively (see the helpers below).

Go deeper: native drill down, other routes, and the CSS trick about 3 min
PatternUser gestureSDK piecesLeaves the iframe?Who renders the detailRow-level security
Native drill downRight-click, then Drill down (or a double-click you wire yourself)Action.DrillDown, HostEvent.DrillDown, disabledActions / hiddenActionsNoThoughtSpotApplied by ThoughtSpot
Left-click to a second LiveboardOne left-click on a mark or cellEmbedEvent.VizPointClick, HostEvent.GetFilters, runtimeFiltersNo (no REST call)ThoughtSpot renders the Liveboard; you render a Back controlApplied by ThoughtSpot; the runtime filters you pass are not a boundary
Right-click to your panelRight-click, then pick your menu itemcustomActions, EmbedEvent.CustomAction, POST /api/rest/2.0/searchdataYes, one REST call from your serverYou render everythingApplied if you call as the viewer; a service token would bypass it

You can run all three on the same Liveboard.

PatternWhen it fitsWhat it costsRow volume and performanceExport and complianceWhat you give up
Native drill downA finer breakdown is enoughNo build, no upkeepHandled by ThoughtSpotSame privileges and RLS as the rest of the embedCustom layout and row links
Second LiveboardA designed detail view with chartsOne handler, a detail Liveboard to maintain, column names kept in stepAggregated viz queries, no raw-row paging of yoursFilters are visible and editable by the userRaw rows and row links
Your own panelRow links, your styling, or a controlled record listServer route, allowlist, paging, UI, upkeep across SDK and cluster upgradesOne warehouse query per page; you set the page size and the ceilingA new place where raw records are shown; you own logging and any copy or export buttonsSimplicity

The cost, volume and compliance columns are our assessment of the trade-offs, not doc-stated. The security column is doc-stated for runtime filters and for calling as the viewer.

ThoughtSpot's menu items Action.DrillDown, Action.DrillInclude, Action.DrillExclude and Action.ShowUnderlyingData work without any code docs. Three levers exist:

  • Remove or grey out. hiddenActions removes items, disabledActions greys them out, visibleActions is an allowlist. Set either hidden or visible, not both docs.
  • Trigger it yourself. HostEvent.DrillDown takes points (clickedPoint, selectedPoints) and either a columnGuid or autoDrillDown (the most popular column). vizId is required in a Liveboard context TS 9.8.0.cl+ docs.
  • Listen for it. EmbedEvent.Drilldown fires after a drill-down happened docs.
liveboardEmbed.on(EmbedEvent.VizPointDoubleClick, (payload) => {
  liveboardEmbed.trigger(HostEvent.DrillDown, {
    points: {
      clickedPoint: payload.data.clickedPoint,
      selectedPoints: payload.data.selectedPoints,
    },
    autoDrillDown: true,          // or columnGuid: '<column-guid>'
    vizId: payload.data.vizId,    // TS 9.8.0.cl+, required on a Liveboard
  });
});
Hiding is cosmetic, not access control

A user who can reach underlying data by another route (for example Explore) still can. The documented control for downloading detailed data (CSV, XLSX) is the CAN_DOWNLOAD_DETAILED_DATA privilege under role-based access control docs. Design your Models and row-level security for the worst case, then hide menu items for UX.

Left-click: one gesture

  • No ThoughtSpot menu while VizPointClick is subscribed.
  • The event fires immediately, with the point.
  • ThoughtSpot's own actions are out of reach on left-click.
  • Leaves the iframe: no. You render a Back control.
Field-observed

Right-click: two gestures

  • ThoughtSpot opens its menu (Filter, Drill down, Show underlying data, SpotIQ analyze, Copy to clipboard) with your item added.
  • Nothing fires until the user picks your item. Then EmbedEvent.CustomAction arrives.
  • Native actions stay available.
  • Leaves the iframe: yes, one REST call. You render the whole panel.
Field-observed
Show underlying data or your own panel? The trade-offs
ThoughtSpot Show underlying dataYour own panel (searchdata)
Build and upkeepNone. Action.ShowUnderlyingData is a menu action docsServer route, UI, paging, upgrades
Listening and triggeringEmbedEvent.ShowUnderlyingData fires on click docs; HostEvent.ShowUnderlyingData opens it docsYou choose the trigger and the layout
ControlsHide or grey it out per embed (cosmetic)Your allowlist, row cap, logging and copy rules
Auditing who saw rowsNo documented hook beyond the event above. Unverified: whether ThoughtSpot logs the viewYour server sees every call, so you can log it

A third route sits between them: the SDK's AnswerService.getUnderlyingDataForPoint inside a callback custom action, then fetchData(offset, size), with no server of your own SDK 1.25.0+ · TS 9.10.0.cl+ docs docs. The cost is that your code runs in the browser and you lose a server point to log, cap or cache. We have not field-tested it here.

Show underlying data: hide it only where your action appears

hiddenActions: [Action.ShowUnderlyingData] is embed-wide, and the docs describe no per-viz version. A CSS rule can hide it only in menus that also contain your custom action. It works because ThoughtSpot renders your action's item with the action id as its DOM id Field-observed.

init({
  // ...
  customizations: {
    style: {
      customCSS: {
        rules_UNSTABLE: {
          // 'view-records' = the id of your custom action
          '[data-testid="popover-container"]:has([id="view-records"]) #context-menu-item-show-underlying-data': {
            display: 'none !important',
          },
        },
      },
    },
  },
});

It depends on internal DOM (rules_UNSTABLE and two ids), so re-test after each cluster upgrade. It is cosmetic. Detailed-data downloads are governed by the CAN_DOWNLOAD_DETAILED_DATA privilege.

Go deeper: security, use cases and a quiz about 4 min
Call searchdata as the user, from your server

Send the end user's own token. ThoughtSpot then applies that user's row-level security and column security to the detail rows, so the panel can never show more than the user may see. In our own tests under cookieless trusted authentication, a direct browser-to-ThoughtSpot REST call was blocked by CORS (field-observed, not doc-verified), which is one reason this guide routes it through a server. Never use an admin or service token for this call: it would bypass row-level security.

  • Privilege. The REST reference says that with role-based access control enabled, searchdata also requires the CAN_DOWNLOAD_DETAILED_DATA privilege, plus at least view access to the data source docs. The RBAC page labels that privilege Early Access and says support must enable it docs; the August 2026 release notes call the granular download privileges generally available docs. The docs conflict; confirm the state on your cluster.
  • Plan for a refusal. Because the privilege gates the call, a user without it may be refused. Show a clear message for 401, 403 and 500 responses instead of an empty panel. Once the granular privileges are enabled, DATADOWNLOADING ceases to exist, and users who had it receive both new privileges automatically while others must be granted them manually docs.
  • A record list is a data export in practice. Our guidance: if you hide the CSV and XLSX download actions for a group, decide on purpose whether your panel should show the same rows to the same group. The docs gate only searchdata and the download privileges, not your UI.
  • No trust in the browser. Your server picks the Model and columns and validates every clicked column against an allowlist. It quotes literals and drops anything it does not recognize.
  • Runtime filters are not a boundary. In the left-click flow, the filters you pass are visible to and editable by the user. Enforce who sees what in ThoughtSpot (RLS, group membership, the token).

Finance: to transactions

Right-click "View transactions", pinned to one viz with vizIds. The backend queries a transaction-level Model with the row's full attribute set, paged. Run the totals check, and show a clear message when the clicked cell is not the measure the drill is about.

Ops: to a second dashboard

Left-click one viz, convert the attributes to runtime filters, merge board filters, then destroy and render the detail Liveboard with a Back button. Alternatively route to a new page in your app and render it there.

Link to the system of record

Same right-click flow. Each row links through a template such as https://crm.example.com/accounts/{Account Id}. URL-encode every value and allow only http(s). A pure link-out needs no ThoughtSpot URL action.

Write-back or workflow

A callback action reads the clicked row and posts to your backend, which acts with its own credentials. Do not rely on client-supplied row data for authorization.

  • Internal portal: row links back to internal systems (CRM, ERP, ticketing) are the main value. Keep the link template in configuration, and keep the http(s)-only check.
  • AI and Spotter: custom actions can also target CustomActionTarget.SPOTTER with MENU or CONTEXTMENU, but modelColumnNames is not supported for that target docs. For native drill from a Spotter answer, HostEvent.DrillDown needs the Answer ID as vizId docs.
  • Mobile and webviews: right-click is a mouse gesture, and the docs do not describe how a context menu is invoked on touch. Consider the MENU or PRIMARY positions, test on real devices, and render the detail panel full-screen.

Multi-tenant SaaS: the per-user token is what keeps tenants apart on the detail query. Cache keys, if you add any, must include the user or tenant. Make sure your token carries the same row-level security (group or attribute) as the embed. Prefer code-based actions: they can be shared with user groups and Orgs, where UI-defined ones are shared with user groups only docs.

Regulated: a common choice is to log, on your server, who opened which record list, and to align the groups that see detail with CAN_DOWNLOAD_DETAILED_DATA (Early Access on one docs page, so confirm it). Hiding menu items is not a control, and caching raw rows on your side adds a copy that your retention rules must cover.

Your right-click panel returns every record when the user clicks a table cell, but it filters correctly when they click a chart bar. Why?

selected* is the cell that was clicked and deselected* is the rest of the row. A bar fills selectedAttributes; a table measure cell does not. Read both buckets (the pointAttributes helper does) and carry every attribute on the row.

0

Custom actions: add your own buttons and menu items

A custom action puts an item in the ThoughtSpot UI that either calls a URL or hands control to your app. Pick the type you need.

URL action

ThoughtSpot POSTs the data

ThoughtSpot sends Answer or viz data straight to your URL. Your app code does not run.

  • Defined in the ThoughtSpot UI only
  • Needs admin or developer privilege
or
Callback action

Your app gets an event

ThoughtSpot emits EmbedEvent.CustomAction and your handler decides what to do.

  • Defined in the UI or in SDK code (customActions)
  • Callbacks need a ThoughtSpot Embedded licence docs

The iframe cannot run your JavaScript, so a callback is a message. Our guidance: treat the handler as a trust boundary and validate every click server-side, since anything the browser sends is untrusted. docs

Which action type?

Which action type?

You scope an action with modelColumnNames for the Revenue column. A user right-clicks a Region cell in a visualization that also contains Revenue. What happens?

The docs describe column scope as the column being present in a visualization, not as a per-cell rule. Check the clicked point in your handler and show a message if it is not the intended one.

Go deeper: URL vs callback table and UI-defined actions about 2 min

ThoughtSpot runs in an iframe and cannot execute your JavaScript. A callback action is therefore a message: the user picks the item, the iframe emits EmbedEvent.CustomAction with an action id and some context, and your handler decides what to do. A URL action skips your app and has ThoughtSpot send the data directly to an endpoint. The docs do not mark either type as beta or early access docs.

URL actionCallback action
What happensThoughtSpot POSTs Answer or viz data to your URLThoughtSpot emits EmbedEvent.CustomAction to your app
How you define itThoughtSpot UI onlyUI or SDK code (customActions)
LicenceNo embedded licence needed; needs admin or developer privilegeCallbacks need a ThoughtSpot Embedded licence docs
Runs your codeNo, your server receives a POSTYes, in the host page
Fits when (our guidance)A fixed hand-off to a service you already run, no host codeYou need the user’s session, UI, or a backend re-check
What it costsAdmin-managed in the UI; not portable as code; credentials stored in ThoughtSpotHandler code to build, secure and monitor; licence
Main riskCredentials are visible to other authenticated users, so a powerful secret leaks docsA trusting handler (our guidance); a missed action id after an experience change

Where you define a callback also matters. UI-defined actions are managed in ThoughtSpot and come as global (on by default for all visualizations, in the More menu) or local (assigned per viz, Answer, or Model). Position options there are primary, menu, and context menu docs, and visibility can be limited by user group docs. You create them under Develop > Customizations > Custom actions docs.

Go deeper: code-based customActions, position by target matrix, and code about 5 min

Code-based custom actions are callback-only and cover Liveboards, visualizations, Answers, and Spotter. They support sharing by groups and orgs, which the docs give as the reason to prefer them for portability docs. GA SDK 1.43.0+ · TS 10.14.0.cl+ docs The same option is on the Liveboard, App, Search, and Spotter view configs docs. The docs introduce the feature in the 10.14.0.cl release notes but do not label it GA explicitly.

TargetPRIMARYMENUCONTEXTMENUScope fields
LIVEBOARDYesYesNot supportedmetadataIds.liveboardIds, orgIds, groupIds
VIZYesYesYesmetadataIds (answerIds, liveboardIds, vizIds), dataModelIds (modelIds, modelColumnNames), orgIds, groupIds
ANSWERYesYesYesmetadataIds.answerIds, dataModelIds (modelIds, modelColumnNames), orgIds, groupIds
SPOTTERNoYesYesdataModelIds.modelIds, orgIds, groupIds; modelColumnNames not supported

Sources: CustomActionTarget, CustomActionsPosition, Spotter. CONTEXTMENU works only for the VIZ, ANSWER, and SPOTTER targets. The UI docs say ThoughtSpot allows only one primary action on a visualization page docs. If a primary-menu action is defined in both the UI and code, only the UI one shows docs. The docs' code examples sometimes spell the scope fields groupId and orgId; the interface table and the enum definitions say groupIds and orgIds, which is what this guide uses docs.

import { LiveboardEmbed, EmbedEvent, CustomActionsPosition, CustomActionTarget } from '@thoughtspot/visual-embed-sdk';

const embed = new LiveboardEmbed(el, {
  liveboardId,
  customActions: [{
    id: 'view-orders',                       // your contract with the handler
    name: 'View orders',                     // label users see
    position: CustomActionsPosition.CONTEXTMENU,
    target: CustomActionTarget.VIZ,
    metadataIds: { vizIds: ['<viz-guid>'] },   // scope: one viz only
  }],
});

embed.on(EmbedEvent.CustomAction, async (payload) => {
  const id = payload?.id ?? payload?.data?.id;      // Classic vs new experience
  if (id !== 'view-orders') return;
  const cmp = payload.contextMenuPoints ?? payload.data?.contextMenuPoints;
  const point = cmp?.clickedPoint ?? cmp?.[0];
  // Send the scope to YOUR backend, which re-checks it and queries with the user's token.
  await fetch('/api/orders', { method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ attributes: point?.selectedAttributes ?? [] }) });
});
embed.render();

The id location differs: the docs show payload.id for classic Liveboards and payload.data.id for the new Liveboard experience and for Answer payloads docs. The payload type, CustomActionPayload, carries id (omitted on Liveboard-level code-based actions), vizId, embedAnswerData, contextMenuPoints, and session docs.

Shape differs from the types

The docs type contextMenuPoints as { clickedPoint, selectedPoints }. On ThoughtSpot 26.8.0.cl with SDK 1.49.0 we saw an array on some surfaces Field-observed. Read both: cmp.clickedPoint ?? cmp[0]. The docs type contextMenuPoints on the payload itself, so the sample above checks payload.contextMenuPoints before payload.data.contextMenuPoints. The event fires only when the user picks the item, not on the right-click itself.

Getting the data behind the click

For the whole viz, use payload.answerService.fetchData(offset, size), which returns a page as { columns, data } docs AnswerService: SDK 1.25.0 · TS 9.10.0.cl+. Observed on 26.8.0.cl: the data is column-oriented (columnDataLite) and values can be JSON strings, so transpose and parse defensively Field-observed.

Liveboard-level callbacks carry no viz data

Docs: a Liveboard-level code-based action does not return viz data in its callback; use the POST /api/rest/2.0/metadata/liveboard/data endpoint docs. Observed: the payload's answerService has no session and fetchData throws; mint one with embed.getAnswerService(vizId) Field-observed.

For record-level drill-through (right-click a number, list the rows behind it), see Drill-down and drill-through. That section covers choosing between a click event and a context-menu action, and fetching detail rows safely.

Go deeper: URL actions: POST, CORS, CSP about 1 min

A URL action POSTs the Answer or viz data to a target. Authentication can be none, basic, bearer, or an X-API-Key header; the credentials are visible to other authenticated users, even without Developer or Admin privileges, so never put a powerful secret there. For embedded use the target domain must be on both the CORS allowlist and the CSP connect-src allowlist. localhost is unsupported (use 127.0.0.1), and the action is disabled on unsaved Answers docs. URL actions cannot be defined in code.

Go deeper: four controls that keep actions tight about 2 min

An action is a way for data to leave the iframe. These four controls limit how far. They are guidance assembled from the scoping and URL-action docs above, not a single documented checklist.

1. Scope

Limit where the action appears: vizIds for one viz, liveboardIds for a board, plus orgIds and groupIds for entitlements. Do not combine vizIds and modelColumnNames expecting AND: the docs describe each extra field as "also shown" and a union for liveboardIds plus vizIds, so treat scopes as additive docs.

2. Allowlist destinations

URL actions need CORS and CSP connect-src entries for the target. Keep that list to exact domains docs.

3. Pick the narrowest position

A context-menu action works from the clicked point, so it exposes less than a viz-level menu action that can pull every row. Choose the position that minimizes the payload. The docs suggest the context menu when you want to send a single row rather than the whole data set docs.

4. Treat the handler as a trust boundary (our guidance)

Anything the browser sends your backend is untrusted. Validate columns against an allowlist, query with the end user's token so RLS applies, and never authorize from client-supplied rows.

Go deeper: what bites, do and don't about 3 min
  • Column scope picks vizzes, not cells. modelColumnNames (format "<modelGuid>::<columnName>") shows the action on visualizations built with the column. The docs describe this as the column's presence in a visualization and do not describe per-cell hiding, so check the clicked point in your handler and show a clear message docs.
  • No Object IDs. The docs say code-based custom actions do not currently support Object IDs docs.
  • Log what your handler did. Configuration problems surface through EmbedEvent.Error codes such as CUSTOM_ACTION_VALIDATION and CONFLICTING_ACTIONS_CONFIG docs. A handler that quietly ignores an unknown action id is invisible, so record the id, the outcome and your backend request id (our guidance).
  • VIZ_ACTION_FILTER_VALIDATION is not fatal. If you route EmbedEvent.Error to a global error handler, filter it out Field-observed.
  • answerService does not serialize. It is a live object, so it will not appear in JSON.stringify(payload), even though it is present Field-observed.
  • File downloads work inside the iframe; window.print() does not. Build files in memory and save as a Blob Field-observed.
  • Match on the action id from both payload.id and payload.data.id.
  • Scope with vizIds alone, or liveboardIds for a whole board (our guidance; scopes are additive).
  • Fetch detail rows on your backend with the end user's token (our guidance).
  • Render action-derived strings with textContent.
  • Rely on column scope to hide an action for a specific cell.
  • Put a high-privilege secret in a URL action credential.
  • Use localhost as a URL action target.
  • Authorize writes from row data the browser supplied (our guidance).

Multi-tenant SaaS: Scope per org with orgIds, and make the backend check the tenant on every call from the handler. A URL action stores one set of credentials that other authenticated users can see, which may not fit per-tenant isolation; a callback to your backend avoids that at the cost of running the handler.

Internal portal: Good place for write-back and workflow triggers: callback to your handler, which calls the system of record with its own credentials. Gate with groupIds.

0

Look and feel: styling, text, icons, and hiding UI

Make the embed look like your product without it breaking on the next ThoughtSpot upgrade.

1CSS variablescustomCSS.variables: supported contract
→
2visualOverridesChart legend, labels, axes. New in SDK 1.49.0
→
3stringIDsRename one exact label
→
4iconSpriteUrlDepends on icon IDs
→
5stringsBroad, can corrupt labels
→
6rules_UNSTABLECan break on upgrade

The docs warn only that rules_UNSTABLE can break on upgrade. Our guidance: the further right you go, the more fidelity you get and the more you re-test, so choose the leftmost layer that does the job. Set it under init({ customizations }).

Hiding a button is not security. hiddenActions removes actions, disabledActions greys them out, and visibleActions is an allowlist (use either visible or hidden, not both). They change what the user sees but do not stop an API call, so pair them with privileges. docs

You need to rename "Pinboards" to "Dashboards" in one place. Which is safest?

strings replaces every case-sensitive occurrence, including inside other labels. stringIDs targets exactly one occurrence and wins over strings.

Go deeper: the layers in detail: variables, rules, CSS file, text, icons, charts about 6 min

ThoughtSpot renders inside an iframe, so your page CSS cannot reach it. Instead you hand the SDK a customizations object at init() time and ThoughtSpot applies it inside the frame. The object has three parts: style (CSS), content (text), and iconSpriteUrl (icons) docs. A per-embed customizations also exists on view configs such as SpotterEmbedViewConfig and AppViewConfig docs.

Inside style you can load a CSS file with customCSSUrl, pass inline CSS with customCSS, or both. The file loads first and the inline CSS loads after it, so inline wins on conflict docs.

Variables are named --ts-var-* (for example --ts-var-root-background, --ts-var-button--primary-background, --ts-var-root-font-family) docs. They are the documented customization surface, and the docs flag only selector rules, not variables, as breakable on upgrade.

init({
  thoughtSpotHost,
  authType,
  customizations: {
    style: {
      customCSS: {
        variables: {
          '--ts-var-root-background': '#F7F5FF',
          '--ts-var-root-font-family': 'Poppins',
          '--ts-var-button--primary-background': '#5B3DF5',
        },
      },
    },
  },
});

For anything with no variable, rules_UNSTABLE takes a selector as the key and an object of CSS properties as the value. This is a documented option, not a feature with a release status; the "_UNSTABLE" suffix is the warning. The docs say these "can break when your instance is upgraded", and you usually need !important to win docs.

customCSS: {
  rules_UNSTABLE: {
    // Why: hide filter option X. Re-test every release.
    '.bk-filter-option': { 'display': 'none !important' },
  },
}

Inline @font-face in rules needs a unique comment prefix as the key, for example '/* ff-400 */ @font-face', so keys do not collide docs.

Host a stylesheet and point customizations.style.customCSSUrl at it. Put globals in :root {}; @font-face can sit anywhere in the file docs. The file's domain must be allowed in both style-src and font-src under Develop > Customizations > Security settings. jsDelivr and fonts.gstatic.com are allowlisted automatically docs.

init({
  thoughtSpotHost,
  authType,
  customizations: {
    style: { customCSSUrl: 'https://cdn.example.com/ts.css' },
  },
});
Mind the casing

The current option is customizations.style.customCSSUrl (capital "CSS"). The top-level customCssUrl on the init config is Deprecated docs.

content.stringIDs overrides one exact text occurrence by ID. SDK 1.38.0+ docs. content.strings matches visible text as a case-sensitive substring and replaces every occurrence, and stringIDs wins when both match. stringIDsUrl loads the same map from a remote JSON file whose domain must be in the CSP and CORS allowlists docs.

customizations: {
  content: {
    stringIDs: { 'Facet.objectType.pinboards': 'Dashboards' },
    // Risky: replaces every occurrence of "Spotter".
    // strings: { 'Spotter': 'dataAnalyzer' },
  },
}

To find IDs, set exposeTranslationIDs: true on the embed (SDK 1.37.0+ · TS 10.9.0.cl+) and restrict it to admin or developer users. Only system text is overridable, not user-created names or titles. Keep placeholders such as {answerName} in your replacement, and sanitize any JSON you load docs.

Set customizations.iconSpriteUrl to an SVG sprite file to replace built-in icons docs. The sprite is a set of SVG <symbol> definitions whose id matches the built-in icon, for example rd-icon-chart-column docs. The icon ID catalog is not reproduced here; look up each ID you need in the icon customization guide before committing to a design.

customizations: { iconSpriteUrl: 'https://cdn.example.com/icons.svg' }
New

visualOverrides (on SearchViewConfig and AppViewConfig) sets init-time chart and table styling for new Answers: legend, data labels, axis, display, columns, and updateMaskPaths docs. The docs label it a new feature, not GA. Use it for chart-level look instead of CSS selectors on SVG internals. See the changelog entry for details docs.

Go deeper: layer table, hiding UI, and do and don't about 3 min
NeedLayerStabilityWhat it costs or what you give up
Colors, fonts, buttons, root backgroundcustomCSS.variablesDocumented surface; the docs give no compatibility guarantee either wayOnly what a variable exposes
Chart legend, labels, axesvisualOverridesNew in SDK 1.49.0New Answers only; needs SDK 1.49.0 and TS 26.6.0.cl
Rename a specific labelcontent.stringIDsExactYou must look up each ID (exposeTranslationIDs)
Swap iconsiconSpriteUrlDepends on icon IDsYou host the sprite; each ID must match a built-in icon
Rename text everywherecontent.stringsBroad, can corrupt labelsHits every case-sensitive substring
Hosted CSS filecustomCSSUrlSame as what you put in itYou host it; domain must be in style-src and font-src. Inline CSS loads after it and wins.
Anything else (hide a region, spacing)rules_UNSTABLECan break on upgradeA re-test on every ThoughtSpot release; !important often needed
Remove a button or menu itemvisibleActions / disabledActions / hiddenActionsUI onlyNot access control

Hiding and disabling UI

Three view-config options control actions: hiddenActions removes them, disabledActions greys them out (disabledActionReason sets the tooltip), and visibleActions is an allowlist. Use either visible or hidden, not both docs. These change what the user sees. They do not stop an API call, so pair them with privileges.

  • Reach for --ts-var-* variables before any selector (our guidance).
  • If you use rules_UNSTABLE, keep a comment next to every selector saying why, and re-test after each ThoughtSpot upgrade (our guidance; the docs only warn they can break).
  • Host CSS, fonts, and string JSON on one allowlisted domain you control (our guidance; fewer allowlist entries to audit).
  • Try changes in the Visual Embed Playground before applying them application-wide. docs
  • Use stringIDs, and allow exposeTranslationIDs only for admin or developer users. docs
  • Do not use strings for short common words; it hits every substring.
  • Do not copy selectors out of browser dev tools into production and forget them.
  • Do not rely on hidden actions as access control.
  • Do not keep the deprecated init-level customCssUrl.
Go deeper: language, number formats, and accessibility about 3 min
SettingWhereWhat it does
localeEmbed view config (AppEmbed, LiveboardEmbed, SearchEmbed, SpotterEmbed and others) SDK 1.9.4 · TS 8.1.0.clLanguage plus default number, date and currency formats. Without it the UI follows the browser or OS locale, and an unsupported locale falls back to en-US. The docs’ fallback example names es-MX, which their own ID list also includes, so check the ID list. docs docs
numberFormatLocale, dateFormatLocale, currencyFormatinit() SDK 1.28.4 · TS 10.0.0.clOverride formats independent of the language docs
preferred_locale, use_browser_languageREST user create, update, importPer-user default. If both are set, the browser language takes precedence. docs

Limits the docs state: object names, titles, descriptions, formulas and user-entered metadata are not translated automatically, and a currency set on upload does not change with locale. For your own UI-string translations the docs point to the Manual Translation REST APIs. docs The docs reviewed do not say how stringIDs overrides interact with a switched locale, so test each locale you ship. Whether to set locale from your app’s language or let the browser decide is a trade-off: matching your app is consistent, while the browser default respects individual users.

Accessibility: not documented

We found no accessibility guidance (conformance level, keyboard or screen-reader behavior) in the developer docs. Ask ThoughtSpot Support for a conformance statement if your procurement needs one, and audit the embed yourself. Our guidance: re-check contrast after any color theming, since variables and CSS can change it.

Go deeper: silent failure when the host is not allowlisted about 1 min
Silent failure when the host is not allowlisted

If your CSS, font, or JSON host is missing from CSP style-src / font-src (and CORS for JSON), nothing errors loudly. The embed renders unstyled. Check the browser Network and Console tabs inside the iframe first docs.

White-label SaaS: use variables plus iconSpriteUrl plus stringIDs, and a hosted CSS file per brand if you have several. Versioning your CSS file URL is a common way to keep a rollback path.

Mobile: the mobile docs say to allow your icon host in both the CSP connect-src and img-src lists for icon overrides. docs

Internal portal: light branding is usually enough: root background, font, and primary button color. Whether rules_UNSTABLE is worth its re-test cost depends on how much pixel parity you need; many internal portals can stay on variables.

11

Spotter and AI: choosing how to add it

Pick how to put ThoughtSpot's AI in your app, and see what happens when a user asks a question.

How should I add AI to my app?

More UI control costs more build effort and shifts governance to you. The Model, not the transport, decides answer quality.

What happens when a user asks a question

User Spotter Model + security Answer Asks a question Pick a model Generate query RLS / CLS applied Answer viz Follow-up in same conversation
  1. User asks
    The question arrives through the embed, the MCP server, or a REST /send call. Either way, the caller's identity is the user's token.
  2. Model selection
    With a model GUID it uses that model. In auto mode on Spotter 3 it discovers the data model for you docs.
  3. Query generated against the Model
    Spotter turns language into a query using your column names, descriptions, synonyms, instructions, and coaching. This is why the Model matters.
  4. RLS and CLS applied
    Security rules run on the query for that user. Spotter, the MCP server, and the REST APIs all enforce them docs.
  5. Answer returned
    You get an answer visualization. Over MCP the default response includes a title, the search query, an embeddable iframe_url, and an answer_id docs.
  6. Follow-up
    The next message goes to the same conversation. Save chats with enable_save_chat (26.5+, default false) on the REST create call docs.
The model and the user's security decide the answer; the transport (embed, MCP, REST) does not change that.

RLS and CLS apply to Spotter, MCP, and REST alike. Runtime filters on Spotter do not replace them.

You pass runtimeFilters to Spotter to keep a user in their region. Is that safe?

Runtime filters are convenience scoping. RLS and CLS are enforced server-side on the user's token.

Go deeper: the options, their trade-offs and status about 3 min
OptionUI controlBuild effortGovernanceStatus riskWhat you give up
SpotterEmbedLow: styling and actionsLowestPrivileges and coaching apply as in the productSpotter 3 is Early Access (below)Custom chat UX
Spotter in AppEmbedLowLowSame; more of the app is exposedNewer options need recent clustersA focused Spotter-only screen
Agent conversation RESTFull: your UIHigh: streaming, rendering, retriesRuns as the user's token; per-user rate limits apply docsGA from 26.5.0.clEmbed features you must rebuild (our guidance)
Spotter MCP serverFull, or the client's own UIMedium: an MCP client and an LLMRLS and CLS enforced; you also govern the LLM and client you connect docsStatus not stated in docsTool-calling behaviour sits with the client

Feature parity: the docs say MCP with Spotter 3 support adds advanced analytics, reasoning, forecasting, automatic data source selection and deep research, and needs 26.2.0.cl or later docs. The docs do not publish a full parity matrix across the four options, so confirm the feature you need on your cluster.

OptionYou getStatus
SpotterEmbedFull chat UI in an iframeGA replaces ConversationEmbed docs
Spotter in AppEmbedSpotter plus the rest of the appSupported; the docs give no separate status
SpotterAgentEmbedHeadless chat via sendMessageDocs: will be deprecated in an upcoming version docs
Agent conversation RESTYour UI, Spotter 3 behind itGA
Legacy REST AI (createConversation, sendMessage, singleAnswer)Spotter Classic over RESTBeta
Spotter MCP serverRemote MCP tools for any clientStatus not stated in docs

The old names (ConversationEmbed, ConversationViewConfig, BodylessConversation) still work "until further notice" but are deprecated docs. The old agent endpoints /ai/agent/{conversation_identifier}/converse and /ai/agent/converse/sse are deprecated; use /ai/agent/conversation/{id}/send and /send/stream docs.

Go deeper: Spotter modes, Spotter 3 and auto mode about 2 min

There are three modes: Spotter Classic, Spotter Agent (Spotter 2), and Spotter 3. Spotter 3 is available from 26.2.0.cl TS 26.2.0.cl+ and adds data-aware analysis, automatic data-model discovery, quick search and deep research, saved chats, and third-party MCP connectors. It is enabled per instance; contact your ThoughtSpot administrator. docs Early access What's new (February 2026) calls Spotter 3 Early Access and disabled by default on embedded instances. The AI embed page gives no status label for it, and only lists individual Spotter 3 features as GA. The FAQ labels Spotter 3 "Recommended" while What's new labels it Early Access, so confirm enablement with ThoughtSpot (neutral reading: recommended where enabled). The docs conflict on wording, so treat Spotter 3 as Early Access, confirm with ThoughtSpot that it is enabled, and do not assume GA. docs

Auto mode (worksheetId: 'auto_mode') lets Spotter 3 pick the data source. It is Spotter 3 only and disabled by default on ThoughtSpot Embedded instances; contact ThoughtSpot Support to enable it docs. Classic and Spotter 2 require a data source ID. In AppEmbed, spotterDataSources accepts 'auto_mode' SDK 1.52.0 · TS 26.10.0.cl docs.

const spotter = new SpotterEmbed(el, {
  frameParams: { height: '100%', width: '100%' },
  worksheetId: '<model-guid>',        // or 'auto_mode' (needs enablement)
  updatedSpotterChatPrompt: true,
  spotterSidebarConfig: {
    enablePastConversationsSidebar: true,
    spotterSidebarTitle: 'My conversations',
    spotterSidebarDefaultExpanded: true,
  },
  defaultQueryMode: SpotterQueryMode.RESEARCH, // SDK 1.52 / 26.9
  // DeepAnalysisPill needs SDK 1.53.0 / 26.10.0.cl
  hiddenActions: [Action.InConversationTraining, Action.DeepAnalysisPill],
});
spotter.render();
spotter.trigger(HostEvent.ResetSpotterConversation);
Go deeper: configuration worth knowing about 2 min
OptionVersionNotes
spotterSidebarConfigSDK 1.47.0 · TS 26.4.0.clSupersedes the standalone enablePastConversationsSidebar flag and takes precedence docs.
spotterChatConfigSDK 1.46.0 · TS 26.4.0.clBrands tool-response cards. starterPrompts need SDK 1.53.0 (TS 26.10.0.cl); spotterFileUploadEnabled (default false) needs SDK 1.49.0 docs.
defaultQueryModeSDK 1.52.0 · TS 26.9.0.clSpotterQueryMode.FAST_SEARCH or SpotterQueryMode.RESEARCH; applies per session and is not persisted as a user preference docs.
dataSourcesSDK 1.52.0 · TS 26.9.0.clArray of model GUIDs, preferred over worksheetId. The docs say the feature is behind a feature flag docs.
runtimeFiltersSDK 1.41 · TS 10.13.0.clPass filters into Spotter. Not a security boundary docs.
enableStopAnswerGenerationEmbedSDK 1.48.0 · TS 26.5.0.clStop button docs.
spotterAnalystConfigSDK 1.53.0 · TS 26.10.0.clspotterAnalystConfig.analystId pins the embed to a single analyst. Relates to the early-access Analysts feature below docs.

The two docs disagree on runtimeFilters in Spotter. The SpotterEmbedViewConfig reference says SDK 1.41.0 and TS 10.13.0.cl docs, while the changelog announces runtime filters and parameters in Spotter under SDK 1.42.0 docs. If you are on 1.41.x, test it before relying on it.

Gate newer options

Many Spotter options need a recent cluster. Check your cluster version, and feature-detect rather than assuming dataSources, auto mode, or spotterAnalystConfig are on.

Go deeper: early-access items about 1 min
  • Early access SpotterViz: an AI panel that builds visualizations when a Liveboard is in edit mode. It is disabled by default; contact ThoughtSpot Support to enable it. Brand it with spotterViz: SpotterVizConfig (starter prompts use SpotterVizStarterPrompt) docs.
  • Early access Analysts: analyst agents in the Spotter sidebar, each scoped to a data model and skill set docs. The embed's Analysts section is disabled by default docs.
Go deeper: make your data model AI-ready, plus do's and don'ts about 3 min
  • Give columns clear names, descriptions, and synonyms in the Model. Spotter reads them.
  • GA Coaching (reference questions and business terms) is on by default in embed from 26.2.0.cl. Hide Add to Coaching for end users with Action.InConversationTraining docs.
  • Set global natural-language instructions with POST /api/rest/2.0/ai/instructions/set. Instructions must not exceed 3000 characters, the token must be scoped to the model's Org, and you need CAN_MANAGE_SPOTTER plus edit access or SPOTTER_COACHING_PRIVILEGE on the model docs.
  • Privileges: CAN_USE_SPOTTER to use it; CAN_MANAGE_SPOTTER for global coaching and instructions (it does not grant model editing) docs. Delegate access with SPOTTER_COACHING_PRIVILEGE (TS 26.3.0.cl) docs.
  • Our guidance: improve the Model and coaching before tuning prompts.
  • Use EmbedEvent.Subscribed before sending host events at load (SDK 1.48.0 · TS 26.4.0.cl) docs.
  • Use conversation_identifier, not conversation_id, in new REST code.
  • Weigh the risk before building on the Beta v1 conversation endpoints; the docs say breaking changes are possible.
  • Do not use runtimeFilters in Spotter to restrict data. Use RLS.
  • Do not set the deprecated enable_contextual_change_analysis, enable_natural_language_answer_generation, or enable_reasoning conversation settings; they are always on in Spotter 3 from 26.2.0.cl.

Multi-tenant SaaS: publish coaching per Org (cross-Org publish needs TS 26.3.0.cl) and share coaching through TML import and export, which SPOTTER_COACHING_PRIVILEGE holders can do docs.

Go deeper: AI governance, who owns what about 3 min
ControlWhat the docs stateChoice or owner (our guidance)
Who may use SpotterCAN_USE_SPOTTER privilege docsGrant by group so access is reviewable. A broad grant is simpler; a narrow grant costs admin time.
Who may change behaviourCAN_MANAGE_SPOTTER for global coaching and instructions. Instructions add business context and do not train or change the LLM docsName a model owner. Central ownership is consistent but slow; letting data owners coach is faster but harder to audit.
Audit AI answersSaved Spotter 3 conversations can be listed and read from 26.7.0.cl, but the list returns only the calling user's own saved chats; no cross-user or admin listing is documented. The docs name audit trails as a use docsThis is not an org-wide audit: it covers only chats saved with enable_save_chat: true (default false), and unsaved chats cannot be retrieved. Saving keeps user questions, so decide retention first.
Data sent to LLMsThe developer docs searched do not state a data-handling position.Unverified. Ask ThoughtSpot for its written statement before a regulated rollout.
ThrottlingPer user per minute: 10 conversation creates, 30 sends. Plan retry logic docsA shared service account hits these limits sooner; per-user tokens spread the load.

Regulated: RLS and CLS are your control. Consider disabling file upload (spotterFileUploadEnabled defaults to false) and review what an MCP client can reach before connecting it.

12

Exports, scheduling, and delivery

Get PDFs, images, and data files out of ThoughtSpot, from the browser or from your server.

In the browser

Client-side (SDK)

The user clicks a download action in the embed, or you call a host event. ThoughtSpot generates the file in the user's session.

  • Always user-scoped
  • Control which actions appear with action IDs
or
On your backend

Server-side (REST)

Your backend calls the report endpoints with a bearer token and saves the bytes. Good for emails, archives, and scheduled jobs.

  • The token decides what data is included
  • The response is a binary with no extension, so you name the file yourself docs

Client-side keeps the export inside the user's session; server-side buys automation and archives but moves custody of the file to you. Either way, the download privilege is the control, not the button. Always pass file_format explicitly.

Plan for these changes

Minute-level schedules are deprecated at 26.8.0.cl, so plan for hourly. The legacy PNG options start erroring at 26.8.0.cl. Continuous PDF is Beta in REST and GA in the SDK from 1.51.0 / 26.8.0.cl. Details are below.

A Liveboard CSV schedule on a dashboard with charts and tables fails. What do you do?

CSV schedules fail on mixed chart-and-table Liveboards. Minute-level frequency is deprecated, and CONTINUOUS is not in the schedule page-size enum.

Multi-tenant SaaS: if RLS must apply per user, call /report/liveboard with that user's token; a shared service token trades that for simpler jobs. Register one webhook per customer Org, because webhooks are Org-scoped.

Regulated: a bucket via storage_destination with HMAC-verified webhooks keeps files in your custody; email is simpler but the file leaves your control. Review the download privileges either way.

Go deeper: enterprise choices, delivery, ownership and exfiltration about 3 min
OptionWhen it fitsWhat it costsRiskWhat you give up
On-demand, client-sideUsers export what they seeLittle; privilege setupAnyone with the privilege can take the data outAutomation, archives
On-demand, your backendYou must log, watermark or archive each exportA service plus token handlingA shared token can bypass per-user RLSSimplicity
Schedule, emailRecurring digests to known readersLowestFiles leave your custody; recipients can be non-usersControl over the copy
Schedule, webhook and bucketYour pipeline must own the fileAn endpoint, HMAC checks, a bucket (S3 or GCS)Your storage becomes the exposureSetup speed
  • Recipients outside the tenant. Schedules take ThoughtSpot users or groups as principals, plus emails for non-users docs. Webhook payloads mark recipients as USER or EXTERNAL_EMAIL docs. A recipient domain allowlist is not stated in the docs searched; if you need one, enforce it in your own scheduling UI (our guidance).
  • Schedule ownership. A schedule records an author, and scheduling for others needs JOBSCHEDULING or ADMINISTRATION. Whether a schedule keeps running after its author loses access is not stated. Inventory schedules with /schedules/search and review them on a cadence (our guidance).
  • Limits. Documented: XLSX max 255 sheets, PNG up to 3840px wide. No report-specific row, size or rate limit was found in the docs searched; the cluster-wide REST rate limit still applies (see Running it in production). Load-test with your largest Liveboard instead of assuming.
  • Exfiltration. Privilege split with RBAC: CAN_DOWNLOAD_DETAILED_DATA for CSV and XLSX, CAN_DOWNLOAD_VISUALS for PNG and PDF docs. Granting visuals but not detailed data is a common way to allow screenshots without raw rows (our guidance).
Go deeper: client-side vs server-side, and the report endpoints about 3 min
QuestionClient-sideServer-side
User clicks a button in your UIYesVia your backend
Attach to an email or store in an archiveNoYes
Include unsaved Liveboard changesYesYes, with transient_content
Runs on a timerNoYes, or use schedules
Respects the viewer's RLSYesOnly if the token is that user's

transient_content comes from the SDK getExportRequestForCurrentPinboard host event, for exports that include unsaved changes docs. From SDK 1.48 read the payload from result.data.v2Content, not result.v2Content docs. This was a breaking change in 1.48, so check any code written against 1.47 or earlier.

POST /api/rest/2.0/report/liveboard exports a Liveboard as PDF, PNG, CSV, or XLSX. Useful parameters: metadata_identifier, tab_identifiers, personalised_view_identifier (TS 10.9.0.cl+), visualization_identifiers, override_filters, runtime_filter, runtime_sort, runtime_param_override, regional_settings docs. Liveboard CSV (a zip of per-viz CSVs) and XLSX (one sheet per viz, max 255 sheets) need TS 26.2.0.cl+ docs.

Docs disagree on the default format

The prose says CSV and the schema says PDF. Always pass file_format explicitly docs.

POST /api/rest/2.0/report/answer exports an Answer as CSV (default), PDF, XLSX, or PNG. With RBAC enabled, the Answer PDF is a table PDF and needs CAN_DOWNLOAD_DETAILED_DATA docs. Its png_options, type (SAVED or PINNED), and personalised_view_identifier are Beta (TS 26.6.0.cl+). png_options takes x_resolution, y_resolution, and scaling.

POST /api/rest/2.0/report/liveboard
{
  "metadata_identifier": "<liveboard-guid>",
  "file_format": "PDF",
  "pdf_options": { "page_size": "A4" }
}
Go deeper: formats, Beta features and who can download about 3 min
FeatureStatusDetails
Continuous PDF (REST)Betapdf_options.page_size: 'CONTINUOUS', zoom_level 45 to 175 (default 100, only valid with CONTINUOUS). Default page size is A4. The REST schema still tags both fields Beta docs.
Continuous PDF (SDK)GAisContinuousLiveboardPDFEnabled: true (LiveboardEmbed and AppEmbed), plus Action, HostEvent, and EmbedEvent named DownloadLiveboardAsContinuousPDF. The 1.48 changelog calls it Beta; the symbol page says GA from SDK 1.51.0 and TS 26.8.0.cl docs. On older SDK or cluster versions, treat it as Beta docs.
High-res PNGBetapng_options.image_resolution, image_scale, include_header. The schema tags them Beta (10.9.0.cl or later) and says to contact support to enable docs. Exports one tab (the first if none given), up to 3840px wide docs.
XLSX / CSV Liveboard download in embedGAisLiveboardXLSXCSVDownloadEnabled and isGranularXLSXCSVSchedulesEnabled. Added in SDK 1.46.0 and TS 26.3.0.cl, GA from SDK 1.51.0 and TS 26.6.0.cl docs.
Plan the PNG migration

The legacy PNG options include_cover_page and include_filter_page were deprecated at 26.4.0.cl and return an error from 26.8.0.cl docs. If you have enabled the new flow, sending the legacy options errors too. The new flow exports one tab per call, so loop over tabs if you need all of them.

Who can download

  • Without RBAC the DATADOWNLOADING privilege governs exports.
  • With RBAC, CSV and XLSX need CAN_DOWNLOAD_DETAILED_DATA; PNG and PDF need CAN_DOWNLOAD_VISUALS docs. These granular privileges are GA per What's new (August 2026) but still Early Access on the RBAC page; the docs conflict, see Hardening.
  • Liveboard-level download action IDs: Action.DownloadLiveboard, Action.DownloadLiveboardAsXlsx, Action.DownloadLiveboardAsCsv docs. Hiding them removes the button; the privilege is what stops the export.
Go deeper: scheduling, Send now and webhooks about 3 min

Schedules are managed with POST /schedules/create, /schedules/search, /schedules/{id}/update (set status to PAUSE to pause or ACTIVE to resume), and /schedules/{id}/delete docs. Rules that bite:

  • description is mandatory. file_format is CSV, PDF, or XLSX (default PDF).
  • The schedule's PDF page_size enum is A4 or TAB_BASED. It has no CONTINUOUS, unlike the report API.
  • On a mixed chart-and-table Liveboard a CSV schedule fails; use PDF or XLSX. Omit pdf_options and the PDF attachment may be empty docs.
  • You need edit access, and ADMINISTRATION or JOBSCHEDULING to schedule for others (with RBAC, JOBSCHEDULING). Default time zone is America/Los_Angeles docs.
Minute-level schedules are deprecated

Minute-level frequency is Deprecated. The deprecation notice says create and update no longer accept minute as a frequency and that existing minute schedules are changed to hourly from 26.8.0.cl. Sub-hourly delivery is available only through Support docs.

The REST changelog words it more softly: every_n_minutes schedules keep running during a transition period and support will be removed in a future release docs. The two pages disagree on timing, so test on your own instance and plan for hourly or longer.

Send now. New Set isSendNowLiveboardSchedulingEnabled: true to add a "Send now" test email to the schedule modal. Related: Action, HostEvent, and EmbedEvent.SendTestScheduleEmail. The host event payload is { sendToSelf: boolean }; the embed event payload is { liveboardId, sendToSelf } (sendToSelf: false sends to all recipients) docs. The changelog marks it as a new feature in 1.48 and does not label it GA. Use it in QA so testers do not wait for the next cron tick.

liveboardEmbed.trigger(HostEvent.SendTestScheduleEmail, { sendToSelf: true });
// requires isSendNowLiveboardSchedulingEnabled: true on the embed

Webhooks. To receive scheduled output in your own system, create a webhook with POST /webhooks/create. The only event today is LIVEBOARD_SCHEDULE. Auth options: API_KEY, BASIC_AUTH, BEARER_TOKEN, OAUTH2. Add signature_verification (HMAC_SHA256) and verify it. You can set storage_destination to AWS_S3 (26.3.0.cl) or GCP_GCS (26.7.0.cl) so files land in your bucket, plus additional_headers (26.4.0.cl) and status (ENABLED or DISABLED, 26.7.0.cl). The update endpoint (/webhooks/{id}/update) also takes reset_options (26.7.0.cl) to clear the authentication, signature, or storage config. It needs ADMINISTRATION or DEVELOPER (or CAN_MANAGE_WEBHOOKS under RBAC) docs. Webhooks are Org-scoped, and the UI docs say only one webhook can be created per Org docs. To run the schedule yourself, see Scheduled report exports.

Go deeper: do's and don'ts about 1 min
  • Pass file_format and set the file extension yourself.
  • Call report APIs with the end user's token when per-user RLS must apply; a shared token is a trade-off.
  • Verify the HMAC signature on every webhook delivery.
  • Our guidance: treat Beta export options as feature-flagged in your code.
  • Do not use override_filters or runtime filters as a security boundary.
  • Do not create new minute-level schedules; the deprecation notice says they are no longer accepted.
  • Do not assume the PNG export covers all tabs.
  • Do not rely on hiding the download button to block exports.
0

Scheduled and recurring report exports

Reports that go out on a timer or an event. Decide who runs the clock, and whose data each file may contain.

1DefinitionStored once per report: Liveboard, format, filter bindings, recipients.
→
2TriggerA cron or an event. Your code turns variables like "latest close" into concrete values.
→
3TokenThoughtSpot call 1: the identity the file is rendered as.
→
4ExportThoughtSpot call 2: report/liveboard returns the file.
→
5DeliverName it, store it, send it. All yours.

With your own scheduler, ThoughtSpot only renders the file. Steps 1, 2 and 5 are yours, and so is the choice of whose identity step 3 uses. The built-in schedule is the lower-effort option when its fixed content is enough. docs

Which model fits you?

Which scheduling model fits you?

The Liveboard has a saved filter on Region. Your scheduler sends runtime_filter with Region = East. What do you get?

The docs say overriding existing filter values with runtime filters on a Liveboard export results in an error. override_filters is the parameter meant to replace a filter on the same column. docs

Multi-tenant SaaS: a fan-out of one job per tenant or per user is an auth question first. The token you mint decides the rows, so test with two tenants before the first send.

Regulated: a file you store is a copy outside ThoughtSpot's controls. Decide retention, access to the link and encryption at rest before you decide the format.

Go deeper: built-in schedule or your own scheduler about 3 min

The built-in schedule is created with POST /api/rest/2.0/schedules/create. Its request has a cron frequency, a time_zone, recipient_details (ThoughtSpot principals plus plain emails), file_format (CSV, PDF, XLSX), optional liveboard_options (visualizations), pdf_options and a personalised_view_id. It has no filter, parameter or event-trigger field docs. Plain emails mean that "our users have no ThoughtSpot account" is not by itself a reason to leave it.

OptionWhen it fitsWhat it costsRiskWhat you give up
Built-in schedule, emailFixed content to known readers, such as an internal digestLowest: one API call per scheduleFiles leave your custody; whose RLS a recipient's copy uses is not stated in the docs searchedPer-run values, event triggers, your own delivery
Built-in schedule, webhook or bucketA pipeline must own the file (archive, SFTP push, warehouse load)An endpoint with HMAC checks, or an S3 or GCS bucketYour storage becomes the exposure; one webhook per OrgPer-run values; the schedule still decides content
Your own schedulerPer-run filters, event triggers, per-customer bindings, your own delivery and auditA scheduler, a worker, a queue, token minting, storageA shared token can bypass per-user RLS; you own retries and failuresThoughtSpot's schedule UI, its run history and its email
  • Frequency. Minute-level schedules are no longer accepted from 26.8.0.cl and existing ones become hourly; sub-hourly needs Support docs. Your own scheduler is not bound by that, but every run is still a REST call.
  • Content limits. A CSV schedule on a Liveboard that mixes charts and tables fails; use PDF or XLSX docs.
  • Run history. schedules/search can return recent runs with include_history_runs docs. With your own scheduler, run history is yours to build.
  • Webhook payload. A multipart request with the file and a JSON payload; msgUniqueId can be used for deduplication docs. Details of webhooks and storage destinations are in Exports, scheduling, and delivery.
Go deeper: who is the report for about 3 min

With your own scheduler, the token your worker uses decides which rows end up in the file. Use auth/token/custom when your row-level security depends on variable values carried in the token (ABAC via RLS). If your RLS comes only from user properties and group membership, the docs list the full token for that case docs. Either way the secret key stays on the server.

AudienceWhen it fitsWhat it costsRiskWhat you give up
One export per user scheduleEach person's file must show only their rowsJobs grow with users x reports; one token per runLowest data risk; highest load and the most schedules to inventoryRun volume
One export per tenant, shared by its usersEveryone in a tenant is entitled to the same rowsJobs grow with tenants; a token that reflects the shared accessIf users in a tenant differ in access, someone sees too muchPer-user precision
One export for all usersNo row-level security applies to the dataLowestA later RLS rule on the table silently changes who should see what, and the shared file ignores itAny per-audience difference
Token values persist on the user

With variable_values, the values are written to the user's profile when the token is created, and later sessions keep using them docs. Our reading: if parallel workers mint tokens for the same ThoughtSpot user with different tenants' values, one run can see another's values. Give each audience its own user, or serialize those runs, and test with two tenants in parallel.

  • The export needs at least view access to the Liveboard, plus the download privilege for the format (DATADOWNLOADING, or CAN_DOWNLOAD_DETAILED_DATA for CSV and XLSX and CAN_DOWNLOAD_VISUALS for PDF and PNG under granular RBAC) docs.
  • In a multi-Org setup the custom token takes org_identifier to scope the Org docs. Mint per Org, and see Who each person becomes.
  • Admins bypass RLS (see Data security), so avoid running a customer-facing report with an admin identity.
Go deeper: use cases and the model each one tends to fit about 3 min

This table is our guidance, not doc-stated. The docs describe the endpoints; the mapping to a business pattern is a judgement you can overrule.

Use caseOften fitsFormat and optionsWatch for
Customer-facing statementsYour scheduler, per user or per tenantPDF. pdf_options offers cover page, custom logo, page numbers, footer text, orientation; regional_settings sets currency and locale docsFiles leave your control once sent; link access, retention and naming are yours
Event-triggered (month-end close)Your schedulerAny. Resolve "latest close date" to a concrete value when the event firesThe built-in schedule takes a cron only, so an event needs your own trigger
Bulk multi-tenant fan-outYour scheduler with a queueSame Liveboard, one token and one set of bindings per tenantOne event hitting every tenant at once; see rate limits in Running it in production
Data feeds (CSV or XLSX to SFTP or a warehouse)Built-in schedule plus webhook, or your schedulerLiveboard CSV returns a ZIP of per-visualization files and XLSX one sheet each, max 255 (26.2.0.cl+). For raw rows, the Data APIs return JSON, capped at 100,000 rows per call docsExport is for reading; large feeds need paging with an explicit record_size, never -1 docs
Internal executive digestBuilt-in schedule to a groupPDFCheck what each recipient's copy shows if RLS applies
Anything sub-hourlySupport request, or your schedulerAnyMinute-level schedules were deprecated at 26.8.0.cl

What will this schedule run with?

1ReadThe Liveboard's saved filters, from TML or a metadata search.
→
2MatchEach saved filter column against your bindings.
→
3RouteSaved column: override_filters. Otherwise runtime_filter.
→
4ShowTell the schedule author what the file will contain.

The docs searched have no endpoint that simply returns a Liveboard's saved filters. There are two readable sources (TML export and a Liveboard metadata search in the V2 format), so your scheduler reads one of them and decides how fresh it must be. docs

Go deeper: building the request about 4 min

POST /api/rest/2.0/report/liveboard accepts three override mechanisms. Which one a binding uses depends on whether the column already has a Liveboard filter docs.

BindingGoes intoEffect
Column with a saved Liveboard filteroverride_filters[] NewReplaces filters on the same column
Column without a saved Liveboard filterruntime_filterAdds a condition
A Parameterruntime_param_overrideSets the Parameter value
"All", column without a saved filterLeave it outNo condition
"All", column with a saved filterNot documentedSee the warning below
Clearing a saved filter is not documented

We found no documented way to turn a saved Liveboard filter into "all" in an export. Test it on your instance, or remove the saved filter from the Liveboard, or export an Answer instead. Treat it as unverified until you have run it.

POST /api/rest/2.0/report/liveboard
Authorization: Bearer <token minted for this audience>
{
  "metadata_identifier": "<liveboard-guid>",
  "file_format": "PDF",
  "override_filters": [
    { "column_name": "Close Date",
      "date_filter": { "type": "EXACT_DATE", "op": "EQ", "epoch": 1759622400 } },
    { "column_name": "Close Type",
      "generic_filter": { "op": "IN", "values": ["Regular"] } }
  ],
  "runtime_filter": { "col1": "Department", "op1": "EQ", "val1": "Sales" },
  "runtime_param_override": { "param1": "Currency", "paramVal1": "USD" }
}
  • Dates. Send one filter object per date column; two on the same date column return an error. Epoch values are numbers, not strings, and the epoch is seconds since 1 Jan 1970 UTC, so convert time zones in your code docs docs. Supported types include EXACT_DATE, EXACT_DATE_RANGE (BW_INC), TODAY, YESTERDAY and LAST_N_PERIOD with datePeriod and number.
  • Column names. Use the display name; for duplicates use WORKSHEET_NAME::COLUMN_NAME. GUIDs are not supported there docs.
  • The file. The Liveboard response has no extension, so add .pdf, .xlsx, .png or .csv yourself. A multi-visualization CSV is a ZIP (26.2.0.cl+) docs. Always pass file_format, because the docs disagree on the default (see Exports).
  • Where to put filters. Our guidance: keep every filter your scheduler controls at Liveboard level and test that a filter saved inside a visualization is not overridden; the docs searched do not state how that case behaves.
  • Load. The call returns the file in the response, so a worker holds a connection for each export. Our guidance: queue the work, cap concurrency and retry with backoff, especially when one event hits many tenants. REST rate limits are covered in Running it in production.
  • Fallback. If a Liveboard fights your filters, POST /api/rest/2.0/report/answer exports an Answer with runtime_filter and runtime_param_override, and names the file after the Answer title docs.
Runtime filters and overrides are not security

They narrow what is shown. Who may see which rows is decided by row-level security on the token docs.

Go deeper: how a scheduler finds the saved filters about 5 min

Four places could tell you what is already filtered. Only the first two read the saved state without a browser.

SourceWhat you getWhat it costsCaveat
TML export
POST /api/rest/2.0/metadata/tml/export docs
The Liveboard definition as JSON (default) or YAML in edoc_format. Request: metadata: [{"type": "LIVEBOARD", "identifier": "<guid>"}]. The 200 response is an array of objects, one per exported item docsNeeds DATADOWNLOADING and at least view access. You parse TML.Unverified: the developer docs I searched do not list the Liveboard TML filters key names (column, operator, values, mandatory flags). Export one of your own Liveboards and read the keys before you code against them. For YAML, the API reference notes a known issue that needs manual formatting.
Metadata search, V2 format GA
POST /api/rest/2.0/metadata/search
With include_details: true and liveboard_response_version: "V2", the detail has a top-level pinboardFilter with filterGroups, filterRows and logicalColumnOrder docs. In the docs' example each row has predicate[].op, selected value entries and negate.One call, no TML parsing. You map column GUIDs to display names yourself.Filters are keyed by the column's GUID (logical_column_id), not its name, and override_filters wants names. The example also shows modified and generationNum on the header, which may help change detection (our reading, not doc-stated).
SDK HostEvent.GetFiltersThe filters currently applied on an embedded Liveboard: column, operator, values, plus applicable_viz (ALL or SPECIFIC) and linking. The docs say it covers Liveboard and runtime filters docsA rendered embed, so it suits the schedule editor in your UI, not a headless worker.It reports the live session. Our reading: it can include a user's unsaved changes, so it is not a guaranteed copy of the saved state.
SDK EmbedEvent.FilterChangedAn event when a user changes a filter, with filter type and name, column name and ID, and runtime filter details docsLow, but only in a browser session.An event stream, not a read of saved state, and it fires on session changes, not on saves.

What does not exist (in the docs searched). No dedicated "get saved filters" endpoint. The Liveboard data response (metadata/liveboard/data) holds columns and rows, not filter definitions. report/liveboard returns a file. A PDF can include a page listing the applied filters (pdf_options.include_filter_page, default true), which a person can read but a program cannot use. We found no webhook or event that says "this Liveboard's filters changed". Treat a miss as "not found", not "proven absent", and re-check after each release.

Test the opposite case too

The docs say override_filters replaces filters on the same column docs. They do not say what it does on a column with no saved filter. If a proof of concept shows it works there, one rule ("always override_filters") removes the need to read saved filters at all. That is a test to run on your instance, not a documented behaviour.

The built-in schedule

  • No filter field. schedules/create has no filter field, as covered above. The one handle on content is personalised_view_id, accepted on create and update since 10.10.0.cl GA docs.
  • What a view is. A saved configuration of a Liveboard that includes specific filter selections docs. List a Liveboard's views with metadata/search, include_details and include_personalised_views. You get view_guid, view_name, author and is_public, but not the view's filter values docs. The one place the docs show a view's filters is the TML for views (view_filters with column, oper and values), and that page marks it Early Access docs.
  • What the recipient copy contains. Not documented in what I found, including which row-level rules apply (see the open question in the model comparison above). What is documented: a schedule PDF can carry a filter page, and a webhook delivery names the view in viewInfo (viewId, viewName) and echoes the export request, including personalised_view_id and include_filter_page docs. It does not list filter values.
  • Where an admin looks. The developer docs do not cover how an admin sees a schedule's effective filters in the product UI. Check the product documentation, and test with a view that has a filter.
Go deeper: keeping bindings fresh, and showing them to the author about 4 min

A saved filter can change after a report is registered. Nobody tells your scheduler. These options are our guidance, because the docs are silent on this problem.

OptionWhen it fitsWhat it costsRiskWhat you give up
Read once at registrationLiveboards that rarely change, few reportsLowest: one call per reportA filter added later makes runtime_filter fail (the docs say overriding a saved filter that way returns an error)Safety after the first day
Re-read before every run and compareReports that must not fail or drift silentlyOne extra call per run; storing the last snapshotLow. A diff can pause the run and alert the ownerSome run time and REST budget, which matters in a fan-out
Re-read on a schedule (for example daily) and flag driftMany reports on a few LiveboardsCalls grow with Liveboards, not runsA window between a change and the next checkImmediate detection
Re-read when the Liveboard changesWould be idealNot available in the docs searched: no change event or webhook foundDo not design around it until it is documentedNot applicable
Always override_filters (if your POC proves it)You control all the filters in the report anywayLowest: no reading, no diffBehaviour on unfiltered columns is undocumented; verify per releaseDoc-backed ground

A cheap guard for any option: when the export errors on a filter conflict, stop that report, alert its owner and keep the other reports running.

A UI idea: show saved filters next to the bindings

In the screen where someone creates a report, load the Liveboard's saved filters and put each binding beside the one it will replace. The author sees, before saving, what the file will run with. This is an invented example (dummy ids, example.com recipients, invented columns):

Report: Quarterly Revenue (Liveboard 00000000-0000-4000-8000-000000000001) · to finance@example.com
ColumnSaved on the LiveboardYour bindingSent as
RegionIN West, East= Eastoverride_filters (replaces)
Order DateLast 3 monthsLatest close monthoverride_filters (date, one object)
Segmentnone= Retailruntime_filter (adds)
ChannelIN Web, Storeno bindingSaved filter stays. Show a notice: "Runs with Web, Store"

Fill the "Saved on the Liveboard" column from one of the sources above, and show its read time ("read 2026-10-15 09:00"). A column that is saved on the Liveboard but missing from your bindings is the row people most often want to see.

Go deeper: do's and don'ts about 1 min
  • Mint the token on your server, for the audience the file is for.
  • Resolve variables such as "today" to UTC epoch numbers before the call.
  • Store the definition (filters, parameters, recipients) in your own database.
  • Inventory your schedules, built-in and your own, and review them on a cadence.
  • Do not use runtime_filter on a column that already has a Liveboard filter.
  • Do not share one file across users whose row-level access differs.
  • Do not run customer reports under an admin identity.
  • Do not create new minute-level schedules.
13

Performance: faster embed loads

See where the time goes when an embed loads, and move it off the user's critical path.

⚡Pre-render with IDFastest on view, but the most resource-intensive. It loads data even if never viewed. For the landing page everyone opens.
◐Pre-render without IDFast shell and a short loading state for data. For many possible Liveboards.
↻On demandFirst visit is normal, revisits are instant, with the lowest waste. The docs call it the best balance.

Faster views cost resources: the docs rank ID-based pre-render fastest and most resource-intensive, and on-demand the best balance. Your traffic pattern and warehouse budget decide. docs

Which pre-render strategy?
A pre-render only helps if its config matches

Use the same liveboardId and the same layout flags in the pre-render and the real embed. Any mismatch reloads it and cancels the benefit.

  • Load path
  • Page shape
  • Operations

High-traffic page: ID-based pre-render on the landing view buys speed but runs queries for users who never look; on-demand is the cheaper alternative. Compare the cost against your warehouse bill.

Mobile: ID-based pre-render spends device memory and data; on-demand and fewer visualizations per screen are lighter (our guidance, the docs give no mobile figures). Lazy loading helps tall boards. Browser data caching needs the Early access flag.

On SDK 1.50, you set lazyLoadFullHeight: true (copied from the guide) but the whole Liveboard still loads at once. Why?

The SDK reference defines lazyLoadingForFullHeight. Parts of the guide use a different key that is not a documented property, so the board loads normally. From SDK 1.52.0 lazy loading is on by default with fullHeight anyway.

Go deeper: where the time goes, and all five strategies about 2 min

An embed load has three phases: the SDK creates the iframe, the frame loads the ThoughtSpot app and authenticates, then the cluster runs the queries behind each visualization. The docs' own checklist covers all three: use the recommended SDK version, pick the right embed package (one Liveboard rather than many visualizations, and fullHeight: true to avoid inner scrolling), prefetch, call init early, cache results by tuning the warehouse, and serve over HTTP/2. docs

StrategySpeed on viewCostUse when
Normal renderSlowest; nothing reusedNone extraRarely visited pages
PrefetchSmall gainFew static assetsCheap warm-up; the strategy table notes that browser cache often makes it redundant
Pre-render with Liveboard IDFastestMost resource-intensive; loads data even if never viewedThe landing analytics page everyone opens
Pre-render without Liveboard IDFast shell, short loading state for dataCommon assets and bootstrap onlyMany possible Liveboards, unknown which
On demandFirst visit normal, revisits instantLowest wasteBest balance for most secondary pages
Go deeper: preRenderConfig, code and rules about 3 min

SDK 1.52.0 introduced a preRenderConfig object (the PreRenderConfig interface) that bundles pre-render settings. It takes precedence when both it and the old top-level properties are set, so existing code keeps working. The reference lists no cluster-version minimum for it. docs docs New

Deprecated top-levelUse instead
preRenderId DeprecatedpreRenderConfig.id
preRenderContainer DeprecatedpreRenderConfig.containerSelector
doNotTrackPreRenderSize DeprecatedpreRenderConfig.doNotTrackSize
Docs example mismatch

Some reference examples (for instance on SpotterEmbedViewConfig and BodylessConversationViewConfig) show preRenderConfig: { preRenderId, zIndex }, which conflicts with the id key in the AppViewConfig reference, the changelog, and the prerender guide. Use id, containerSelector, and doNotTrackSize, and test against your SDK version. The reference does not list the full member set of PreRenderConfig, so treat zIndex as unverified. docs docs

// Early, e.g. after sign-in: build the hidden embed.
const pre = new LiveboardEmbed('#analytics-host', {
  liveboardId,
  preRenderConfig: { id: 'main-board', containerSelector: '#prerender-stage' },
});
await pre.preRender();             // preRender(showPreRenderByDefault = false, replaceExistingPreRender = false)

// Later, when the user opens the page: same liveboardId, same flags, same id.
// The prerender guide shows a normal render() picking up the pre-rendered iframe.
const view = new LiveboardEmbed('#analytics-host', {
  liveboardId,
  preRenderConfig: { id: 'main-board' },
});
await view.render();               // methods also available: showPreRender(), hidePreRender(), syncPreRenderStyle(), getPreRenderIds()

Methods: preRender, showPreRender, hidePreRender, syncPreRenderStyle, getPreRenderIds. docs The example is our sketch of the documented API; the prerender guide has the canonical sample. In React use PreRenderedLiveboardEmbed. docs

  • Use the same liveboardId and the same layout and behavior flags (for example isLiveboardCompactHeaderEnabled, isLiveboardMasterpiecesEnabled) in the pre-render and the real embed. docs
  • Keep the pre-render container mounted for the pre-render's lifetime, and prefer a CSS selector over an element reference. docs
  • Do not change config between pre-render and use; the instance cannot be reused and reloads, cancelling the benefit.
  • Do not remount the container (common in React); it orphans the pre-render wrapper.
  • Do not pre-render with an ID for views most users never open.
Go deeper: fullHeight, lazy loading and the data cache about 3 min

fullHeight: true resizes the container to the Liveboard height so the page scrolls instead of the frame. On its own it loads all visualizations at once, which works best with fewer visualizations. With fullHeight: false (default), only visualizations in the viewport load first and the rest load as you scroll. docs

lazyLoadingForFullHeight gives you both: full height plus lazy loading, with lazyLoadingMargin (a CSS margin string) to start loading before a visualization scrolls into view. SDK 1.40.0+ · TS 10.12.0.cl+ docs The default depends on your SDK version. The property reference says false. From SDK 1.52.0, the changelog and lazy-load guide say it defaults to true whenever fullHeight is on for LiveboardEmbed or AppEmbed, together with enableScrollableContainerLazyLoading (true) and lazyLoadingMargin ('500px 0px'); any value you set, including false, overrides it. New docs docs

new LiveboardEmbed('#container', {
  liveboardId,
  fullHeight: true,
  lazyLoadingForFullHeight: true,   // note the exact key name; explicit on SDK < 1.52, default-on from 1.52
  lazyLoadingMargin: '100px',       // 1.52+ default is '500px 0px'
  minimumHeight: 400,                // only with fullHeight: true
});
Key-name typo in the docs

Parts of the lazy-load guide (the first code sample and the viewport section) use lazyLoadFullHeight, while the newer "Lazy loading" section and the SDK reference use lazyLoadingForFullHeight. The reference name is the real property. On an SDK older than 1.52.0 a wrong key does nothing and raises no error that we could confirm in the docs, so the Liveboard loads everything at once. docs docs

minimumHeight (SDK 1.44.2 | TS 10.15.0.cl) applies only with fullHeight: true. The reference gives the older defaultHeight (SDK 1.5.0) the same description; prefer minimumHeight, which the guide documents. docs If the Liveboard sits in a scrollable or clipped container rather than the window, enableScrollableContainerLazyLoading computes the visible region against those ancestors; the reference shows no version for it, and the 1.52.0 changelog makes it default to true with fullHeight. docs docs

Liveboard data cache

enableLiveboardDataCache caches visualization data in the browser and adds a refresh control to the header, with a companion RefreshLiveboardBrowserCache event and host event. Early access The embed guide labels it an Early Access feature that is disabled by default; you contact ThoughtSpot support to enable it. The changelog entry (SDK 1.49.x) tags it only as new and does not call it GA. Confirm with support before relying on it. docs docs

Cached data is stale data

Fresher data costs speed; faster data risks stale numbers. Decide who may see old numbers and for how long, then expose the refresh action accordingly. This is a design judgment, not a documented limit.

Go deeper: cost, freshness and budgets about 3 min
ChoiceWhen it fitsWhat it costsRiskWhat you give up
ID-based pre-renderOne view nearly everyone opensQueries and browser resources for every session; the docs call it the most resource-intensiveWasted load when users never lookWarehouse headroom
On-demandRevisits are commonNormal first loadSlow first viewFirst-visit speed
Warehouse-side cachingMany users hit the same dataWarehouse tuning, which the docs list as a lever docsStaleness depends on warehouse settingsReal-time numbers
Browser data cache (Early access)Repeat opens of the same boardSupport enablementStale numbers per userFreshness unless the refresh control is exposed

Budgets and monitoring. The docs give no target render times or memory figures, so any number is yours to set (our guidance). Real-user timing shows true conditions but needs your telemetry; a synthetic check is steadier but misses real networks and data. Many teams use both. Track cold and warm renders separately, per strategy, using the timing sketch below.

Go deeper: prefetch, many embeds, fetch discipline and measuring about 3 min

Prefetch and init

Set callPrefetch: true in init(), or call the standalone prefetch(host, [PrefetchFeatures.LiveboardEmbed, PrefetchFeatures.VizEmbed]) before init. It only helps if it runs on an earlier page than the embed page. The best-practices page recommends calling prefetch before init, while the prerender guide notes that browser cache often makes prefetch redundant. docs docs Calling init() early at app start lets authentication run while the user is still on another page. docs

Many embeds on one page

  • Prefer one Liveboard to several single-viz embeds; if you must, prefetch and keep to about four per page. docs
  • Set queueMultiRenders: true in init() so each embed starts after the previous one completes, reducing browser load (SDK 1.5.0). docs
  • Use showPreviewLoader (SDK 1.32.0 | TS 10.0.0.cl, viz embeds with vizId only) to show a preview image while a viz loads. docs

Fetch discipline in your own code

Your page often makes its own REST calls: listing Liveboards, loading filter values, discovering columns. The usual traps are not specific to ThoughtSpot. This is general engineering practice, not a documented rule:

  • Do one fetch and derive from it. For example, load a filter's values once and compute dependent lists locally rather than calling per control.
  • Cache by object id, and share an in-flight request between callers so two components asking for the same thing produce one call.
  • Do not start a REST call per visualization on page load (an N+1 pattern); batch or defer until needed.
  • Under cookieless trusted auth, send browser REST calls through the SDK's tokenizedFetch (SDK 1.28.0), which adds the bearer token the SDK already holds, so you do not mint a second one per call. docs See Authentication.

Measuring

The docs do not prescribe a measurement method; this is general practice. Mark performance.now() when you call render() and when LiveboardRendered fires, and log the difference by environment and strategy. Compare cold and warm loads separately. Check the browser network panel to see whether time is in assets, auth, or slow queries; query time points to the warehouse, which the docs list as a lever. docs

const t0 = performance.now();
embed.on(EmbedEvent.LiveboardRendered, () => {
  report('liveboard_render_ms', performance.now() - t0);
});
await embed.render();

enableV2Shell_experimental (SDK 1.31.2 | TS 10.0.0.cl) is documented as a lighter shell that "can provide performance benefits" but is experimental; do not use it in production without testing. docs

0

Environments and releases: dev, test, prod

Build in dev, prove it in test, ship it to prod. TML carries content between them, and obj_id tells each environment which object a file belongs to.

Dev Org authors build and edit here the only place content changes Test Org receives imports only testers and UAT, no edits Prod Org receives imports only your end users are here Git release test prod 1tag what ships 2 export TML 3 pull request 4 validate, then import 5test and sign off 6 pull request 7 validate, then import
  1. Build in dev and tag the release
    All authoring happens in the dev Org. Mark the objects that make up the release, including the Models and Tables a Liveboard depends on. docs
  2. Export the TML to a release branch
    POST /api/rest/2.0/metadata/tml/export writes one file per object. Each file carries its obj_id, so it can be imported into any Org. docs
  3. Open a pull request to the test branch
    The docs move release TML into each environment's deployment branch by pull or merge request. Reviewers read a YAML diff of exactly what will change. docs
  4. Validate, then import into test
    Run the import with import_policy: VALIDATE_ONLY first, then import for real. Import matches on obj_id: it updates an object that already exists and creates one that does not.
  5. Test and sign off
    Testers work in the test Org. Nobody edits content there; a fix goes back to dev and comes through the flow again.
  6. Pull request to the prod branch
    Approving this pull request is the production sign-off. Who must approve is your decision; the docs recommend a pull request and validation, not a specific approver.
  7. Validate, then import into prod
    The same files that passed in test are imported into prod. Newly created objects are not shared with anyone, so share them after the import; the docs say standard content in prod should only be shared read-only. docs
Content only moves forward through Git. Test and prod never change by hand, so every release is reviewable and repeatable.

Edit only in dev. Test and prod receive imports, never hand edits; the next import would overwrite them anyway.

obj_id: how an object finds itself in the next environment

GUID

Unique per instance

Every object has a GUID. GUIDs cannot repeat in different Orgs, so the same Liveboard has a different GUID in dev, test and prod. docs

vs
obj_id

Unique per Org, set by you

Each Org can hold one object with a given obj_id, so Sales-KPIs can exist once in dev, once in test and once in prod. Org plus obj_id identifies the object like a GUID does. docs

Import looks for Org + obj_id first, then GUID, and creates the object if neither matches. That is why TML with obj_id moves between Orgs without GUID mapping. docs

Turn it on before you build the process

The docs list obj_id as a prerequisite for any deployment process. Objects get an automatic obj_id only after their first change once it is enabled. docs

Give objects readable ids

Set them with POST /api/rest/2.0/metadata/update-obj-id, in bulk. For existing environments, use your GUID mapping files once to align the other Orgs, then deploy by obj_id from then on. docs

Names are free, ids are not

Import never matches on object names, so renaming a Liveboard is safe. Changing an obj_id after it has been promoted means the next import creates a new object downstream (our reading of the matching rule). docs

Tables are the exception

Table objects match on the physical table in the database, not on obj_id or GUID. Each Connection holds one Table object per database table. docs

Publish or import TML?

Both put the same content in more than one Org. They differ in whether each Org gets its own copy. docs

Publishing

One object, visible in many Orgs

  • Fits: a multi-tenant instance where tenant Orgs need the same, standardized content.
  • The object lives in the Primary Org; no copies, so no GUID mapping.
  • Per-Org differences come only from variables on Connections and Tables.
  • An update in the publisher Org reaches every Org it is published to at once.
  • Gives up: per-Org customization beyond variables, and works within one instance.
or
TML import

A separate copy in each Org

  • Fits: dev to test to prod, Orgs that need unique customizations, or moving between instances.
  • Full freedom to vary each copy.
  • Gives up: one copy per Org to deploy and keep in step, and more memory use.

A common combination: TML import moves a release from dev to test to prod, and in a prod made of many single-tenant Orgs, that last import goes into the publisher Org, which updates every published Org at once. docs

Connections and tables across environments

Content points at data through Connections and Tables, and those usually differ by environment (database, schema, credentials). The docs give two ways to handle it; many teams also keep Connections out of Git entirely.

OptionHow it worksTrade-off
Variables (parameterization)Connection and Table properties such as db and schema reference variables (${dc_db}) whose values are set per Org. Works with both Publishing and TML import. docs docsOne set of TML for every environment. Variables must be created and assigned in each Org first.
GUID mapping find/replaceWith ThoughtSpot's Git integration, the mapping file can rewrite strings such as db: DEVDB to db: PRODDB during deploy. docsExact string replacement across all deployed files; scope it per file with additionalMapping when one global rule could hit the wrong file.
A same-named Connection in each OrgEach Org has its own Connection, pointing at its own database, with the same name. Connections are never deployed; only content that references them is.Credentials never travel through Git. You must keep names in step by hand. This is a field practice from pipelines we have built, not a doc-stated pattern.

Import settings that matter

import_policy

VALIDATE_ONLY checks without changing anything, ALL_OR_NONE applies everything or nothing, and PARTIAL (the default) imports what it can. docs

force_create

Creates a new object for every file, ignoring matches. Useful for deliberate copies; harmful in a normal promotion. docs

Deletes do not flow

TML import only creates or updates, so removing a file does not delete anything downstream. docs ThoughtSpot's Git deploy differs: with deploy_type: FULL, objects deleted in the commit are deleted from the target. docs Choose deliberately, and check for dependent user content before deleting.

One deploy at a time

Parallel deployment to several Orgs on one cluster is not supported; run them one after another. docs

Multi-tenant SaaS: the docs' standard pattern is one prod Org with row-level security filtering shared Models for every customer. Per-customer Orgs fit single-tenant databases, deployed by Publishing or per-Org import. docs

Regulated: the pull request into the prod branch is a natural approval record. Who approves, and whether a second gate is needed, is your policy; the docs do not prescribe it.

Go deeper: three ways to move TML, the export and import tools about 3 min

Separate instances per environment are also possible. Plan version order, because the docs advise against importing TML from a newer ThoughtSpot version into an older one. docs

Three ways to move TML

OptionWhen it fitsWhat it costsRisk / what you give up
ThoughtSpot Git integration (/vcs/git APIs, or Version Control in the UI)Your repo is on GitHub or GitHub Enterprise and you want GUID mapping and deploy handled for you.Needs a repo-scope token and the right privilege. Follow the docs' layout: one repository, one branch per Org, a branch for configuration files, a branch for version history.docsGitHub and GitHub Enterprise only. Sequential multi-Org deploys.
TML export/import scripts with any Git providerAnother Git host, or you want your own release package and filtering. The docs say this gives more flexibility for new setups.docs docsYou build and maintain the scripts. Objects need obj_id values, and Tables and Connections need parameterizing with variables where they differ.You own diffing, retries and error handling. Keep version-control branches separate from deploy branches.
Manual TML import in the UIOne-off moves or early prototypes.No pipeline to build.Our reading: no review trail or repeatability unless you add them yourself.
ToolWhat it doesWatch for
POST /api/rest/2.0/metadata/tml/exportExports objects as TML (JSON default, or YAML). export_fqn adds FQNs so same-name objects are unambiguous. Needs DATADOWNLOADING. docsWith export_associated, consider fetching one object at a time. YAML TML needs manual formatting (a known issue).
POST /api/rest/2.0/metadata/tml/importimport_policy is PARTIAL (default), ALL_OR_NONE, VALIDATE_ONLY or PARTIAL_OBJECT. Needs DATAMANAGEMENT or ADMINISTRATION. docsImport related objects (connection, tables, models, Liveboards) in one call. Importing only a Worksheet or model can take a few minutes before it is usable.
POST /vcs/git/config/createLinks an Org to a GitHub or GitHub Enterprise repo. enable_guid_mapping defaults to true. docsNeeds an access token with repo scope and the administration privilege (or CAN_SETUP_VERSION_CONTROL under RBAC).
POST /vcs/git/branches/commitCommits TML from an Org to a branch. docsCommit from dev only. delete_aware defaults to true, so TML files for objects no longer in the Org are deleted from the branch.
POST /vcs/git/commits/deployDeploys a branch into the linked Org. deploy_type: DELTA (default) or FULL. deploy_policy: ALL_OR_NONE (default), PARTIAL, VALIDATE_ONLY. docsDeploys the branch head unless you pass commit_id. Pass a tag's commit SHA for releases.
Not on GitHub?

Use the TML export and import endpoints from your own pipeline and store the files in your repo yourself. ThoughtSpot also documents a Git provider integration pattern, where your provider's CI runs TML export and import, and says it gives more flexibility than the GitHub-specific APIs for new setups.docs

// POST /api/rest/2.0/vcs/git/commits/deploy  (CI step 1)
{ "branch_name": "test_deploy", "deploy_type": "DELTA", "deploy_policy": "VALIDATE_ONLY" }

// CI step 2: same call with deploy_policy "ALL_OR_NONE" and a commit_id for releases
Go deeper: branch scheme and what does not travel with TML about 2 min

ThoughtSpot's Git guidance suggests a main branch for actions and workflows (no TML), plus per-environment branches such as dev_vc, release, test_deploy, test_vc, uat_deploy, prod_deploy, prod_vc, and one version-control branch per single-tenant customer Org. This is guidance, not a requirement.docs

  • Sharing, tags and custom actions on newly created objects: set them after deploy.docs
  • Spotter coaching: coaching can be published to other Orgs, and users with SPOTTER_COACHING_PRIVILEGE can import and export coaching TML on the data model (26.3.0.cl+).docs
  • Personalized Views: import with enable_personalized_view_upsert to update by obj_id instead of duplicating. Enabling it on your instance is a separate request to ThoughtSpot. Early access
  • Spotter memory can be exported and imported between environments with /ai/memory/export and /ai/memory/import (26.8.0.cl). Import replaces the memory of the target data models rather than appending, so validate with dry_run first.docs
Go deeper: SDK version strategy and the recommended-SDK table about 3 min

ThoughtSpot ships a new SDK version with each major Cloud release and tells you to use the recommended SDK version for your release. Each SDK symbol carries an "SDK x | ThoughtSpot y" badge, which is the minimum for both. Check it against your cluster before you use a flag.docs docs

  • Use the SDK version the docs recommend for your Cloud release. This one is doc-stated.docs
  • Read the changelog and What's new for breaking changes before every bump (doc-stated).
  • Run new opt-in flags (for example useHostEventsV2, which the routing page still calls beta) in dev and test before prod.docs
  • An unversioned CDN URL resolves to the latest build and can change under you. The quickstart example imports that way, so treat it as a trial pattern.docs
  • Do not jump to a flag whose "TS" badge is newer than your cluster. Features from a newer release may not exist yet.
  • Our guidance: avoid changing the SDK and a cluster setting in the same release without a test pass, so a regression has one suspect.

SDK pinning strategy: options

OptionWhen it fitsWhat it costsRisk / what you give up
Exact npm version plus lockfileYou want repeatable builds and a clear rollback (revert the lockfile).You must schedule bumps yourself.You fall behind the recommended version and miss fixes if bumps slip.
Semver range (patch updates) with CI testsYou have strong automated embed tests.CI must catch regressions.A patch can still change behaviour. Flags tagged for a newer cluster may not exist on yours.
Unversioned CDN importPrototypes only.None.The SDK can change under you with no review. Not suited to production, in our view.

Which option is right is your decision. The docs state only that you should use the recommended version and read the changelog. Whether a self-hosted copy beats npm is outside what the docs cover.

Recommended SDK per Cloud release

Cloud releaseRecommended SDKNotes
10.15.0.cl (Jan 2026)1.44.0+No breaking changes.
26.2.0.cl1.45.0+API parameter deprecations.
26.3.0.cl1.46.0+Feature deprecations.
26.4.0.cl1.47.0+Variable API and metadata parameterization endpoints deprecated.
26.5.0.cl1.48.0+Breaking changes to Spotter APIs.
26.6.0.cl1.49.0+No breaking changes.
26.7.0.cl1.50.0+Breaking changes to SpotterCode
26.8.0.cl (Aug 2026)1.51.0+Breaking changes and deprecations. Several Liveboard defaults flipped on.
26.9.0.cl (Sep 2026)1.52.0+No breaking changes.
26.10.0.cl (Oct 2026)1.53.0+Includes a deprecation (HostEvent v1 framework) and an announced change to API rate limits (a per-Org limit from 26.11.0.cl).

Source: What's new. The cloud-release rows are the "Upgrade notes" and "Recommended SDK versions" lines on that page.

Check your defaults after an upgrade

At 26.8.0.cl (SDK 1.51.x), several Liveboard options became on by default on embedded instances, including isLiveboardCompactHeaderEnabled (with breaking UI changes), isLiveboardMasterpiecesEnabled, hideIrrelevantChipsInLiveboardTabs, coverAndFilterOptionInPDF and isEnhancedFilterInteractivityEnabled. They were Early Access before. Compare screenshots before and after.docs

Go deeper: release coordination, rollback, approval and testing about 3 min

The docs give the mechanics (Git, TML, validate, revert). The process around them is yours. Each row below separates what the docs state from our guidance.

ConcernOptionsTrade-offsDoc-stated or our guidance
Release coordination with Cloud releasesTrack each release's upgrade notes and recommended SDK; test the new SDK in dev before prod; or hold the SDK until your cluster is upgraded.Faster adoption brings new features sooner and more regressions to test. Waiting risks deprecations landing before you act.Doc-stated: What's new gives a "Recommended SDK versions" line and "Upgrade notes" per release, and the best-practices page says to use the recommended version.docs docs Unverified: the developer docs do not describe your upgrade schedule or notice period. Ask ThoughtSpot Support.
Rollback of a TML deploy(a) Revert with /vcs/git/commits/{commit_id}/revert, optionally per object. (b) Redeploy an earlier tagged commit with commit_id. (c) Re-import TML you exported before the deploy.(a) Documented, supports ALL_OR_NONE or PARTIAL. (b) Fits a tag-based flow, but a DELTA deploy applies only what that commit changed, so check the result. (c) Needs a pre-deploy export step.Revert and deploy_type behaviour are doc-stated.docs docs The docs do not publish a rollback procedure, so rehearse yours in test. The docs also advise a version-control branch for prod Orgs, because end-user content is otherwise not archived.docs
Change approvalPR approval on environment branches; a CI gate that runs VALIDATE_ONLY; a separate approver for the prod deploy job.Stronger audit trail, slower releases. Small teams may accept lighter gates.The docs recommend a pull request to move changes and validation before deploying. They do not prescribe who must approve. Approvals are our guidance.docs
Testing strategyManual smoke test on test; scripted embed tests against test; plus a tenant-isolation test with users from two tenants.Scripted tests cost upkeep but catch default changes after an upgrade (see the 26.8.0.cl defaults callout above).Our guidance. The docs say to set sharing on newly deployed objects, so include a check as a non-admin user.docs
Go deeper: deprecation watch about 2 min

Treat deprecations as dated work. The most urgent items for an embedder are below. The complete table, with replacements, is in What's new and feature status.

ItemDateAction
HostEvent v1 framework DeprecatedDeprecated at 26.10.0.cl, end of support March 2027. V2 is opt-in per embed and the routing page still calls it beta; the docs conflict on whether it becomes the default at 26.10.0.clSet useHostEventsV2: true in dev and test now, inventory every trigger() call, and fix payloads and context. docs
Legacy variable update and delete APIs, and metadata parameterizationDeprecated 26.4.0.cl. The deprecation table lists end of support October 2026; the changelog says only "a future release"For variables, use /template/variables/{identifier}/update-values and /template/variables/delete. The replacement for metadata parameterization was not confirmed. docs docs
Top-level preRenderId, preRenderContainer, doNotTrackPreRenderSizeDeprecated SDK 1.52.0Use preRenderConfig, which takes precedence. docs
JWT filter_rules (and parameter_values for RLS)filter_rules closed to new configurations at 26.3.0.cl (existing keep working), removal date not stated. parameter_values supported but "will be deprecated"; the docs conflict on whether it is already deprecated. user_parameters deprecated 10.4.0.clMove to ABAC via RLS with custom variables and variable_values. docs
every_n_minutes schedulesDeprecated 26.8Use hourly or longer. The deprecation notice and the REST changelog differ on timing; see the Exports section. docs
Process tip

Re-read What's new and the changelog once per Cloud release, with the diff of your own embed code open. Put each deprecation on your backlog with the date from the table.

15

Go-live checklist

What to check before launch, and what to do on the day. Your progress is saved in this browser.

1T minus 1 dayFreeze content and SDK versions. Run the full checklist on test. Confirm your rehearsed rollback path and the previous SDK version.
→
2DeployValidate, then deploy the commit that test verified. Set sharing, tags and custom actions on new objects. docs
→
3Smoke testSign in as a non-admin user in each tenant. Check the embed renders, a filter applies, an export works and no other tenant's data appears.
→
4WatchWatch token-service errors, auth failures and embed errors for the first day. Keep the runbook and rollback path open.

An unchecked Required item blocks launch. If you use items apply only when you do. Recommended items are doc best practice or our guidance, so a dated follow-up is your risk call.

Required means the docs state it or it is security-critical. If you use X means skip it when you do not. Recommended is a doc best practice or our guidance.

  • Auth & identity
  • Data security
  • Hardening
  • Embed code
  • Filters & interactions
  • AI
  • Performance
  • Scheduled exports
  • Observability & support
  • Release management
Do not skip the tenant-leak test

Runtime filters and hidden actions can look like isolation in a demo. Only RLS or ABAC is a security boundary, so test isolation with two users from different tenants.docs

Run the whole list against test, with a non-admin user, before the first prod deploy.

16

What's new and feature status

The newest releases, what is being removed, and which features are still Beta or Early Access.

As of 15 October 2026: SDK 1.53.0 and ThoughtSpot Cloud 26.10.0.cl are the latest released versions the docs describe. What's new lists 26.10.0.cl with "Recommended SDK versions: 1.53.0 or later". The What's new page also announces a per-Org API rate limit starting at 26.11.0.cl.

Visual Embed SDK 1.53.0

October 2026. Adds spotterAnalystConfig, Spotter conversation pinning, starterPrompts, isScopedLiveboardFilteringEnabled and new host events (GetGroups, OpenParameter).

SDK 1.53.0 · TS 26.10.0.cldocs

Cloud 26.9.0.cl

September 2026, recommended SDK 1.52.0 and later. No breaking changes. SDK 1.52.0 added preRenderConfig and the redesigned Spotter opt-in.

Recommended SDK 1.52.0+docs

Cloud 26.10.0.cl

The current release, October 2026, recommended SDK 1.53.0 and later. Deprecates the HostEvent v1 framework and brings Spotter Analyst embed.

Recommended SDK 1.53.0+docs

Cloud 26.11.0.cl

Upcoming. Starts a 50 requests per second per Org REST limit on instances with Orgs. See Running it in production.

docs

Latest SDK with a changelog entry is 1.53.0 (October 2026); the latest Cloud release is 26.10.0.cl. docs

Biggest deadline: HostEvent v1

The HostEvent v1 framework is deprecated in 26.10.0.cl (October 2026) and support ends in March 2027. The routing page still labels the v2 framework beta and disabled by default, so the docs conflict on whether it is already the default.docs

Two things to remember

Early Access means the feature is off by default; ask your administrator or ThoughtSpot Support to enable it.docs Version numbers changed after 10.15.0.cl (January 2026): releases are now 26.N.0.cl, and 26.2.0.cl is February 2026. Each What's new release entry carries a "Release version", "Upgrade notes" (for example "includes breaking changes") and "Recommended SDK versions" line, so read those first when planning an upgrade.docs

Feature status index

Generated from every badge in this guide that names a feature. Filter by status before you depend on one.

FeatureStatusVersionSectionSource
Go deeper: notable embed features, last 12 months about 4 min

Every row shows the minimum SDK and cluster. Check both against what you run before you use a flag.

WhenFeatureStatusVersionWhat it does
Sep 2025Persona-based left nav and home (discoveryExperience)Early accessSDK 1.41.0New full-app navigation, Early Access at launch. From 26.8.0.cl the V3 navigation is the default and V1/V2 are deprecated.docs
Nov 2025Code-based custom actionsGASDK 1.43.0 · TS 10.14.0.clDefine custom actions in code with CustomActionTarget and CustomActionsPosition.docs
Jan 2026API interception (interceptUrls, EmbedEvent.ApiIntercept)NewSDK 1.43.0 · TS 10.15.0.clLet the host allow, modify or block API calls and search execution. The changelog lists it under 1.44.x.docs
Feb 2026Spotter 3 in embed (updatedSpotterChatPrompt, auto mode, error enums)Early accessSDK 1.45.0 · TS 26.2.0.clWhat's new calls Spotter 3 Early Access and off by default. Auto mode is worksheetId: 'auto_mode'. ErrorDetailsTypes and EmbedErrorCodes give structured error handling.docs
Mar 2026enableLinkOverridesV2NewSDK 1.46.0 · TS 26.2.0.clRecommended over linkOverride for overriding ThoughtSpot URL navigation. Links route through EmbedEvent.DialogOpen.
Mar 2026Host Events V2 (useHostEventsV2, getCurrentContext())BetaSDK 1.45.2 · TS 26.3.0.clContext-aware routing and stricter payload validation. The routing page says it is beta and disabled by default (the migration guide no longer carries a Beta label); the deprecation page says it replaces v1 as the default at 26.10.0.cl (the docs conflict; see Host Events V2 in Lifecycle).docs
Apr 2026Spotter chat-history sidebar (spotterSidebarConfig) and spotterChatConfigNewSDK 1.47.0 · TS 26.4.0.clTitle, default-expanded state and labels for the sidebar, plus tool-card branding (spotterChatConfig, SDK 1.46.0). The chat-history panel is off by default in embed.
Apr 2026Centralized Liveboard filter modal (isCentralizedLiveboardFilterUXEnabled)Early accessSDK 1.47.0 · TS 26.4.0.clOne modal for many filters. Off by default at launch; What's new (26.10.0.cl) says it is now generally available and on by default on embedded instances, so the Early access label here is historical (see the Oct 2026 row).
Apr 2026personalizedViewIdNewSDK 1.47.0 · TS 26.4.0.clEmbed a saved Personalized View of a Liveboard, together with liveboardId. The docs do not label this flag Beta or Early Access.
Apr 2026Collections REST APIs (/collections/create, search, {id}/update, delete)BetaTS 26.4.0.clCreate, search, update and delete Collections.
May 2026EmbedEvent.SubscribedNewSDK 1.48.0 · TS 26.4.0.clFires when a host-event listener is registered. Use it to avoid races when sending host events at load. The symbol page tags it 26.4.0.cl; the changelog lists it under 1.48.x (May 2026).docs
May 2026Spotter agent REST: /ai/agent/conversation/{id}/send and /send/streamNewTS 26.5.0.clReplace the legacy converse endpoints. /conversation/create (from 10.13.0.cl) now requires metadata_context (DATA_SOURCE or AUTO_MODE), a breaking change.docs
May 2026Continuous Liveboard PDF (isContinuousLiveboardPDFEnabled)BetaSDK 1.48.x · TS 26.5.0.clRenders a Liveboard tab on one PDF page that matches the screen layout. The 1.48 changelog calls it Beta; the LiveboardViewConfig symbol page says the SDK flag is GA from SDK 1.51.0 and TS 26.8.0.cl, while the REST page_size: 'CONTINUOUS' option is still tagged Beta (the docs conflict; see Exports).docs
Jun 2026visualOverrides and enableLiveboardDataCacheNewSDK 1.49.0 · TS 26.6.0.clInit-time chart and table styling for new Answers. The Liveboard browser-cache refresh is documented as Early Access and off by default.docs cache
Jul 2026SpotterViz (spotterViz, SpotterVizConfig)Early accessSDK 1.50.0 · TS 26.7.0.clBrand and customize the conversational Liveboard builder panel. Disabled by default.docs
Jul 2026HomePage.Focused (V4 home) in discoveryExperience.homePageEarly accessSDK 1.50.0 · TS 26.7.0.clA focused home page for full-app embed. The docs mark it Early Access and disabled by default.docs
Aug 2026Spotter AnalystsEarly accessSDK 1.51.0 · TS 26.8.0.clAnalyst agents in the sidebar. Disabled by default in embed.docs
Aug 2026Liveboard defaults flipped on (hide irrelevant filters, compact header, masterpieces, enhanced filter interactivity, cover and filter page in PDF)GASDK 1.51.0 · TS 26.8.0.clThese were Early Access and are now on by default on embedded instances. The compact header has breaking UI changes.
Sep 2026preRenderConfigNewSDK 1.52.0One object for pre-render ID, container and size tracking. Takes precedence over the deprecated top-level props. The same release makes lazyLoadingForFullHeight default to true with fullHeight (changelog), while the property reference still says false: the docs conflict; see Performance.docs
Sep 2026updatedSpotterExperience and spotterShareConversationConfigNewSDK 1.52.0 · TS 26.9.0.clOpt-in redesigned Spotter UI, and sharing saved conversations with users and groups. Sharing is off until enableShareConversation is true. See the callout below on the UI default.
Sep 2026HomeLeftNavItem.CollectionsNewSDK 1.52.0 · TS 26.9.0.clShow Collections in the full-app left nav. Shown when Collections are enabled on the cluster.docs
Oct 2026spotterAnalystConfig.analystId and Spotter conversation pinning (spotterChatPinConfig)NewSDK 1.53.0 · TS 26.10.0.clPin a SpotterEmbed to one governed Analyst; let users pin conversations at the top of the history list.docs
Oct 2026Contextual filtering in Liveboards (isScopedLiveboardFilteringEnabled, applicability, HostEvent.GetGroups, HostEvent.OpenParameter)Early accessSDK 1.53.0 · TS 26.10.0.clThree filter levels: Liveboard, tab and group. applicability scopes UpdateFilters and UpdateParameters to a tab or group. What's new also says the centralized filter modal is generally available and on by default.docs
Oct 2026Column security rules on LiveboardsNewTS 26.10.0.clLiveboards that CSR used to block now open with CSR enforced, including in embeds. Filter values from inaccessible columns are masked, and scheduled deliveries apply CSR per recipient.docs
Other Beta and Early Access items worth knowing

User-defined object IDs (/metadata/update-obj-id) are Beta since 10.8.0.cl.docs Personalized Views portability (TML author and obj_id fields) is Early Access as of August 2026.docs The granular download privileges CAN_DOWNLOAD_VISUALS and CAN_DOWNLOAD_DETAILED_DATA are GA per What's new (August 2026) but Early Access per the RBAC page, which says Support must enable them. The docs conflict; confirm on your cluster (see Hardening).docs

Go deeper: deprecations and breaking changes about 4 min

Master list: deprecated features. Items near the top are most urgent. Dates come from that table unless a row says otherwise.

ItemDeadlineReplacement / action
HostEvent v1 frameworkDeprecated 26.10.0.cl (Oct 2026); end of support Mar 2027Enable useHostEventsV2 in non-prod and test. V2 validates payloads and routes by context. docs migration
Custom action UI workflow and /customization/custom-actions REST endpointsAnnounced: deprecated from 26.12.0.cl (Dec 2026); removal June 2027Code-based custom actions. Existing API integrations keep working until removal. docs
POST /auth/token/objectAnnounced: deprecated from 26.12.0.cl (Dec 2026); removal June 2027Use /auth/token/full or /auth/token/custom. See the token endpoints table in Auth methods. docs
Legacy Variable APIs: /template/variables/{identifier}/delete and /template/variables/update-values; /metadata/parameterizeDeprecated 26.4.0.cl; table lists removal Oct 2026 (this month; confirm on your cluster)Use POST /template/variables/{identifier}/update-values, POST /template/variables/delete and /metadata/parameterize-fields. docs
PREVIEW_THOUGHTSPOT_SAGE privilegeRenamed 26.3.0.cl; removal 26.9.0.cl (Sep 2026). Date has passed; the docs do not confirm removal, so check your clusterUse CAN_USE_SPOTTER. Roles on RBAC instances need to be recreated with the new privilege name. docs
V1 and V2 full-app nav and homeDeprecated Feb 2026; V3 default from 26.8.0.cl; removal Aug 2026. Date has passed; the docs do not confirm removal, so check your clusterTest with the V3 experience. docs
Legacy Liveboard PNG export workflowDeprecated 26.4.0.cl; removal Aug 2026. Date has passed; the docs do not confirm removal, so check your clusterNew high-resolution PNG flow in /report/liveboard. docs
Answer classic Data panel; dataPanelV2Deprecated 26.4.0.cl; classic removal Aug 2026. Date has passed; the docs do not confirm removal, so check your clusterThe new panel is always on, and dataPanelV2 is deprecated (SDK 1.46.x). docs
Liveboard and Answer "discoverable" settingsDeprecated 26.2.0.cl (Feb 2026); removal Aug 2026. Date has passed; the docs do not confirm removal, so check your clusterShare explicitly with users and groups before upgrading. docs
Top-level preRenderId, preRenderContainer, doNotTrackPreRenderSizeDeprecated SDK 1.52.0preRenderConfig. docs
Top-level enablePastConversationsSidebarDeprecated SDK 1.47.0 · TS 26.4.0.clspotterSidebarConfig.enablePastConversationsSidebar. docs
defaultHeightDeprecated SDK 1.44.2minimumHeight. docs
isLiveboardStylingAndGroupingDeprecated SDK 1.45.0isLiveboardMasterpiecesEnabled (default on from 26.8.0.cl). docs
British-spelling personalized view symbols (HostEvent.UpdatePersonalisedView, EmbedEvent.SavePersonalisedView, Action.PersonalisedViewsDropdown)Deprecated SDK 1.48.0 · TS 26.5.0.clThe ...Personalized... spellings. HostEvent.UpdatePersonalizedView is itself tagged deprecated at SDK 1.53.0 in favor of HostEvent.SelectPersonalizedView. docs
linkOverrideSuperseded SDK 1.46.0enableLinkOverridesV2 is recommended. docs
/ai/agent/{id}/converse and /ai/agent/converse/sseDeprecated 26.5.0.cl; removal date not stated/ai/agent/conversation/{id}/send and /send/stream. docs
Conversation create: data_source / guid, answer_context, liveboard_contextBreaking at 26.5.0.cl (the context options are removed)metadata_context with AUTO_MODE or DATA_SOURCE. docs
HostEvent.GetExportRequestForCurrentPinboard payload shapeBreaking SDK 1.48.xRead result.data.v2Content instead of result.v2Content. docs
Token filter_rules and parameter_values (JWT ABAC)filter_rules closed to new configs 26.3.0.cl; parameter_values supported, "will be deprecated" (docs conflict); removal date not statedvariable_values with ABAC via RLS variables. docs
minute as a schedule frequency (/schedules/create, /update)Rejected from 26.8.0.cl; existing schedules become hourlyUse hourly or longer. Contact ThoughtSpot Support if you need sub-hourly. docs
SpotterCode MCP unauthenticated use of /mcpAuthentication required from Aug 2026Sign in through OAuth/SAML or a bearer token, or use the /mcp/docs endpoint for documentation lookup only. docs
user_parameters in auth/token/full and objectDeprecated 10.4.0.cl; removal date not statedauth/token/custom. docs
Sage / SageEmbed, Worksheets, customCssUrlAlready removed or past end of support (Sage Dec 2025, Worksheets Sep 2025, customCssUrl Jul 2024)Spotter, Models, customizations.style.customCSSUrl. docs
Where the docs conflict

Host Events V2: the deprecation page says v2 replaces v1 as the default in 26.10.0.cl, while the routing page still calls v2 beta and disabled by default (the migration guide dropped its Beta label). Treat v2 as opt-in (Beta, per-embed useHostEventsV2) until you confirm the default on your cluster, and test it now.docs Redesigned Spotter UI: the symbol pages say updatedSpotterExperience defaults to false and What's new says classic stays the default for existing deployments, but the Spotter customization page says the new experience is enabled by default on 26.9.0.cl. Set the flag explicitly.docs Variable APIs: the deprecation table lists removal in October 2026, but the changelog says the legacy endpoints work "until further notice" and will go in a future release. JWT ABAC: What's new and the REST changelog say parameter_values is not deprecated yet (only filter_rules is closed to new configurations), while the ABAC page says filter rules and parameters are both deprecated. See Legacy ABAC. Confirm production deadlines with ThoughtSpot Support.

17

About this guide, and what changed

How the guide is kept current, what it is checked against, and its version history.

How to read the advice

The docs say soStated as fact, with a link to the page. Prescriptive only here, or where the risk is one-sided.
Options and trade-offsWhere the docs leave the choice to you, the guide lays out the options, what each costs, and what tips the decision.
Our guidanceLabelled as such. A common choice, not a rule. Your license, compliance needs and IdP may point elsewhere.
Field-observedMeasured on a live instance, with the version. Not a documented guarantee; re-test after upgrades.

How it stays current

Grounded on the docs

Claims are checked against the ThoughtSpot developer documentation, and each one links to the page it came from. When the docs and live behaviour disagree, the guide says so.

Re-verified on a schedule

Each section shows the date it was last verified. A section that has not been re-checked within days is flagged ⚠ until someone verifies it again.

Versioned

Every edit bumps the guide version and the last-updated date, and adds a line to the log below.

Verify before you rely on it

Treat this guide as a strong starting point, not a contract. Version gates, defaults and API shapes change between releases. When your security posture depends on a detail, confirm it against the linked docs for your release, or with ThoughtSpot Support.

What changed

    0

    Running it in production

    Launch is the start. Four habits keep an embed healthy: observe, alert, respond, review. ThoughtSpot gives you the signals; the alerting and the on-call process are yours to build.

    1 Observe◉Collect signalsEmbed errors in the browser, token-service logs in your backend, security audit logs from ThoughtSpot. docs
    2 Alert⚑Decide what pages someoneThe docs define no alerting. Our guidance: page on token mint failures and a rising auth-failure rate.
    3 Respond⚙Know the first checksIs the cluster under maintenance? Is it stopped? Is your token service up? Then open a Support ticket. docs
    4 Review↻Learn from incidentsCheck audit logs after an incident; re-test on each Cloud release.

    ThoughtSpot exposes signals (embed events, audit logs, a maintenance banner), not a monitoring product for your embed, so your backend and your SIEM own the alerting.

    Two ways to get security logs

    Pull via REST

    Call POST /api/rest/2.0/logs/fetch on a schedule and forward to your own store. You control cadence and format, and you must keep the job running. docs

    Push to your SIEM

    The docs say logs can be pushed to a SIEM server. Less code to own, but the setup lives in the product docs, not the developer docs. docs

    Regulated: the audit log records login failures and auth-token creation (LOGIN_FAILED, FAILED_TO_CREATE_AUTH_TOKEN, AUTH_TOKEN_CREATED_SUCCESSFULLY). Our guidance: store it outside ThoughtSpot for your own retention period. docs

    Multi-tenant SaaS: on Orgs-enabled clusters the API returns the current Org's logs; the primary Org (Org 0) admin can fetch all Orgs with get_all_logs set to true. docs

    Go deeper: log API limits, rate limits and embed errors about 4 min
    TopicWhat the docs sayOur guidance
    Audit log fetchNeeds the ADMINISTRATION privilege (Admin Control privileges if RBAC is on). Default window is the last 24 hours; a request must not span more than 24 hours, so use several sequential requests for longer ranges. The REST reference tags it TS 9.0.0.cl+; get_all_logs is from 9.10.5.cl. docs REST reference Docs conflict: the REST schema shows get_all_logs with a default of true, while the audit-logs page describes it as applying "if set to true".Poll in windows under 24 hours with an overlap, and de-duplicate on the event id. Set get_all_logs explicitly and check what your cluster returns. Use a dedicated identity for the log job, keep its credentials in a secrets store, and review who holds it. No log-only privilege is documented: the fetch needs ADMINISTRATION (or Admin Control with RBAC), so this identity is powerful.
    REST rate limitOn 26.2.0.cl+, 100 requests per second per client IP across all public APIs combined, plus a burst of 10, then HTTP 429. Async status endpoints allow 100 per minute with a burst of 100. To change them, contact Support. Announced: from 26.11.0.cl, ThoughtSpot will also enforce 50 requests per second per Org across all public REST endpoints on instances with Orgs, independent of the per-IP limit; either limit can then return HTTP 429. docsThe per-IP limit is shared by many backends behind one NAT. Our inference: the Org limit is shared by every client of that Org, so a tenant fan-out (for example scheduled exports) can hit it. Retry 429 with exponential backoff and throttle bursts.
    Spotter API limitsPer user, per minute: 10 conversation creations, 30 messages per conversation. The docs advise retry logic. docsQueue and back off on your side.
    Embed errorsEmbedEvent.Error carries an errorType (such as API), and auth problems also surface as AuthStatus.FAILURE, EmbedEvent.AuthFailure, AuthExpire and NoCookieAccess. docsSend each one to your logging with a user and Org id plus a request id of your own, so a browser error can be joined to a token-service log line. The docs give no ThoughtSpot-side correlation id for this.
    Go deeper: maintenance, stopped clusters, webhooks and Support about 4 min
    • Maintenance. GET /api/rest/2.0/system/banner returns under_maintenance and banner_text; the docs suggest showing the text to users in your app. States are ACTIVE, UNDER_MAINTENANCE, STOPPED and STARTING/PENDING. docs Our guidance: poll it from your status check and show a friendly message instead of a blank embed.
    • Economy mode (idle sensing). It cuts cost by stopping an idle cluster (default threshold 120 minutes) and is enabled through Support. A stopped cluster is restarted with ?tse=true&start_cluster=true plus the security-key header, and CAPTCHA restart is not supported for embedded deployments. Keep it off in production if users must never meet a cold start (our guidance). docs
    • Webhooks. The documented webhook delivers Liveboard schedule events, with HMAC-SHA256 signing, and one webhook per Org. Delivery is checked with /api/rest/2.0/jobs/history/communication-channels/search (Beta). In our reading, it is not an operational alert feed. docs docs
    • Support. Feature enablement, rate-limit changes and economy mode all go through a Support ticket. docs The docs also point to the Community and Discord for developer help.
    What the docs do not cover

    We found no published status page, uptime SLA, release calendar or maintenance-window schedule, no capacity or consumption sizing guidance, no usage-monitoring or system-Liveboard guide, and no SIEM setup steps in the developer docs. Take these from your contract and your ThoughtSpot account team, not from this guide. Recommended SDK versions per Cloud release are in What's new.